@yaag/cli 0.2.1 → 0.4.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/assets/types/runtime/cassette-schema.d.ts +1 -0
- package/assets/types/runtime/cassette.d.ts +3 -1
- package/assets/types/runtime/program-hash.d.ts +6 -0
- package/assets/types/runtime/resume-identity.d.ts +14 -0
- package/assets/types/runtime/run-checkpoint.d.ts +7 -1
- package/assets/types/runtime/run.d.ts +5 -0
- package/package.json +3 -3
- package/src/argv.ts +105 -12
- package/src/cli-tree-host.ts +1 -25
- package/src/cli.ts +5 -17
- package/src/computed-imports.ts +101 -0
- package/src/eval-fd.ts +35 -0
- package/src/inline-imports.ts +76 -0
- package/src/inline-program.ts +49 -0
- package/src/program-loader.ts +28 -0
- package/src/run-invocation.ts +6 -2
- package/src/run-program.ts +58 -0
|
@@ -13,6 +13,7 @@ export declare const CassetteSchema: Type.TObject<{
|
|
|
13
13
|
run: Type.TOptional<Type.TObject<{
|
|
14
14
|
outcome: Type.TUnion<Type.TLiteral<"completed" | "failed" | "interrupted" | "paused" | "stopped">[]>;
|
|
15
15
|
programFile: Type.TOptional<Type.TString>;
|
|
16
|
+
programSource: Type.TOptional<Type.TString>;
|
|
16
17
|
args: Type.TOptional<Type.TUnknown>;
|
|
17
18
|
programHash: Type.TOptional<Type.TString>;
|
|
18
19
|
}>>;
|
|
@@ -11,8 +11,10 @@ export interface CassetteRun {
|
|
|
11
11
|
readonly outcome: RunOutcome;
|
|
12
12
|
/** Invocation identity: what a resume is about to re-execute. */
|
|
13
13
|
readonly programFile?: string;
|
|
14
|
+
/** Inline Program source text; identity for a Run with no program file (ADR-0033). */
|
|
15
|
+
readonly programSource?: string;
|
|
14
16
|
readonly args?: unknown;
|
|
15
|
-
/** Advisory content hash of the program file; SHA-256 hex. */
|
|
17
|
+
/** Advisory content hash of the program file, or of the inline source; SHA-256 hex. */
|
|
16
18
|
readonly programHash?: string;
|
|
17
19
|
}
|
|
18
20
|
/** Versioned JSON artifact containing the frames exchanged during one Run. */
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One advisory content hash, shared by the modules that record a program's
|
|
3
|
+
* identity and the modules that compare it (ADR-0033).
|
|
4
|
+
*/
|
|
5
|
+
/** Advisory content hash of a program's bytes or inline source; SHA-256 hex. */
|
|
6
|
+
export declare function sha256Hex(input: string | ArrayBuffer): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The advisory comparison between a loaded Cassette's recorded program identity
|
|
3
|
+
* and the program this Run executes (ADR-0033).
|
|
4
|
+
*/
|
|
5
|
+
import type { Cassette } from "./cassette.ts";
|
|
6
|
+
import type { ProgramIdentity } from "./run-checkpoint.ts";
|
|
7
|
+
/**
|
|
8
|
+
* Warns when a Cassette's recorded program identity does not match the program
|
|
9
|
+
* this Run executes. Advisory only: the caller always supplies the program and
|
|
10
|
+
* the Cassette supplies the history, so a mismatch never stops the Run
|
|
11
|
+
* (ADR-0033). Returns null when nothing inline is involved, so file-to-file
|
|
12
|
+
* resume behaviour is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
export declare function programIdentityWarning(cassette: Cassette, path: string, program: ProgramIdentity): string | null;
|
|
@@ -44,7 +44,13 @@ export declare function publishRunCheckpoint(options: RunCheckpointOptions): Pro
|
|
|
44
44
|
* today's rule that its directory must already exist.
|
|
45
45
|
*/
|
|
46
46
|
export declare function ensureCheckpointDirectory(record: string | undefined, destination: string): Promise<void>;
|
|
47
|
+
/** Which program a Run executes: a file on disk, or inline source text (ADR-0033). */
|
|
48
|
+
export interface ProgramIdentity {
|
|
49
|
+
readonly programFile?: string;
|
|
50
|
+
/** Inline source, exactly as the caller gave it; it excludes any loader prelude. */
|
|
51
|
+
readonly programSource?: string;
|
|
52
|
+
}
|
|
47
53
|
/** Resolves what a resume re-executes. The program hash is advisory only. */
|
|
48
|
-
export declare function resolveRunIdentity(
|
|
54
|
+
export declare function resolveRunIdentity(program: ProgramIdentity, args: unknown): Promise<RunIdentity>;
|
|
49
55
|
/** Reports a secondary Cassette publication failure without displacing the Run's primary error. */
|
|
50
56
|
export declare function writeRecordingDiagnostic(error: unknown): void;
|
|
@@ -16,6 +16,11 @@ export interface RunOptions {
|
|
|
16
16
|
* extension declarations. This is Run context, never an Agent working directory.
|
|
17
17
|
*/
|
|
18
18
|
readonly programFile?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Inline Program source text, recorded as the Run's identity when there is no
|
|
21
|
+
* program file (ADR-0033). It never resolves extension paths.
|
|
22
|
+
*/
|
|
23
|
+
readonly programSource?: string;
|
|
19
24
|
/** Resolves pi's enabled skills for live restriction requests. */
|
|
20
25
|
readonly skillProbe?: SkillProbeFactory;
|
|
21
26
|
/** Write every public transport-seam frame once the Run settles. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yaag/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@earendil-works/pi-tui": "^0.84.0",
|
|
24
|
-
"@yaag/runtime": "0.
|
|
25
|
-
"@yaag/tui": "0.
|
|
24
|
+
"@yaag/runtime": "0.4.0",
|
|
25
|
+
"@yaag/tui": "0.4.0",
|
|
26
26
|
"typebox": "1.3.7"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/argv.ts
CHANGED
|
@@ -6,16 +6,37 @@ import { resolve } from "node:path";
|
|
|
6
6
|
|
|
7
7
|
export const USAGE = `usage:
|
|
8
8
|
yaag run <program.ts> [--quiet] [--args <json>] [--record <file>] [--replay <file>] [--resume <file>] [--events-fd <n>]
|
|
9
|
+
yaag run --eval <source> [--quiet] [--args <json>] [--record <file>] [--replay <file>] [--resume <file>] [--events-fd <n>]
|
|
10
|
+
yaag run --eval-fd <n> [--quiet] [--args <json>] [--record <file>] [--replay <file>] [--resume <file>] [--events-fd <n>]
|
|
9
11
|
yaag describe <program.ts>
|
|
10
12
|
yaag setup-workspace [dir]
|
|
11
13
|
|
|
14
|
+
--eval runs an Orchestration Program that you give as source text. Give a
|
|
15
|
+
program file or --eval, but do not give both. A program that --eval runs can
|
|
16
|
+
import "@yaag/runtime" and "typebox" only. A program that imports other modules
|
|
17
|
+
must be a file.
|
|
18
|
+
|
|
19
|
+
--eval-fd reads the program source from descriptor <n>, and needs the writer to
|
|
20
|
+
close that descriptor. It keeps the source out of the process argument list,
|
|
21
|
+
where every local user can read it. The yaag extension always uses it.
|
|
22
|
+
|
|
23
|
+
A --resume or --replay Run also needs the program: give the program file,
|
|
24
|
+
--eval <source>, or --eval-fd <n>. A Cassette holds the history of a Run, and never the program
|
|
25
|
+
to run.
|
|
26
|
+
|
|
12
27
|
Warning: describe imports the module and executes its top level. Keep program module top level side-effect free.`;
|
|
13
28
|
|
|
29
|
+
/** The program one `run` invocation names: a file, or source text (ADR-0033). */
|
|
30
|
+
export type ProgramSource =
|
|
31
|
+
| { readonly kind: "file"; readonly file: string }
|
|
32
|
+
| { readonly kind: "inline"; readonly source: string }
|
|
33
|
+
| { readonly kind: "inline-fd"; readonly fd: number };
|
|
34
|
+
|
|
14
35
|
export type ParsedArgv =
|
|
15
36
|
| {
|
|
16
37
|
readonly ok: true;
|
|
17
38
|
readonly command: "run";
|
|
18
|
-
readonly
|
|
39
|
+
readonly program: ProgramSource;
|
|
19
40
|
/** Opaque: parsed JSON, never validated here (ADR-0010). */
|
|
20
41
|
readonly args: unknown;
|
|
21
42
|
readonly quiet: boolean;
|
|
@@ -44,6 +65,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgv {
|
|
|
44
65
|
|
|
45
66
|
function parseRun(tokens: readonly string[]): ParsedArgv {
|
|
46
67
|
let file: string | undefined;
|
|
68
|
+
let evalSource: string | undefined;
|
|
69
|
+
let evalFd: number | undefined;
|
|
47
70
|
let args: unknown = {};
|
|
48
71
|
let eventsFd: number | undefined;
|
|
49
72
|
let record: string | undefined;
|
|
@@ -57,6 +80,8 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
|
|
|
57
80
|
quiet = true;
|
|
58
81
|
} else if (
|
|
59
82
|
token === "--args" ||
|
|
83
|
+
token === "--eval" ||
|
|
84
|
+
token === "--eval-fd" ||
|
|
60
85
|
token === "--events-fd" ||
|
|
61
86
|
token === "--record" ||
|
|
62
87
|
token === "--replay" ||
|
|
@@ -69,6 +94,12 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
|
|
|
69
94
|
const parsed = parseJson(value);
|
|
70
95
|
if (!parsed.ok) return parsed;
|
|
71
96
|
args = parsed.value;
|
|
97
|
+
} else if (token === "--eval") {
|
|
98
|
+
evalSource = value;
|
|
99
|
+
} else if (token === "--eval-fd") {
|
|
100
|
+
const parsed = parseEvalDescriptor(value);
|
|
101
|
+
if (!parsed.ok) return parsed;
|
|
102
|
+
evalFd = parsed.value;
|
|
72
103
|
} else if (token === "--events-fd") {
|
|
73
104
|
const parsed = parseDescriptor(value);
|
|
74
105
|
if (!parsed.ok) return parsed;
|
|
@@ -89,20 +120,14 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
|
|
|
89
120
|
}
|
|
90
121
|
}
|
|
91
122
|
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (resume !== undefined && replay !== undefined) {
|
|
97
|
-
return failure(`--resume and --replay cannot be used together\n${USAGE}`);
|
|
98
|
-
}
|
|
99
|
-
if (resume !== undefined && record !== undefined && resolve(resume) === resolve(record)) {
|
|
100
|
-
return failure(`--resume and --record must use different paths\n${USAGE}`);
|
|
101
|
-
}
|
|
123
|
+
const clash = conflict({ file, evalSource, evalFd, eventsFd, record, replay, resume });
|
|
124
|
+
if (clash !== null) return failure(`${clash}\n${USAGE}`);
|
|
125
|
+
const program = programSource(file, evalSource, evalFd);
|
|
126
|
+
if (program === undefined) return missingProgram(resume, replay);
|
|
102
127
|
return {
|
|
103
128
|
ok: true,
|
|
104
129
|
command: "run",
|
|
105
|
-
|
|
130
|
+
program,
|
|
106
131
|
args,
|
|
107
132
|
quiet,
|
|
108
133
|
...(eventsFd === undefined ? {} : { eventsFd }),
|
|
@@ -112,6 +137,66 @@ function parseRun(tokens: readonly string[]): ParsedArgv {
|
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
139
|
|
|
140
|
+
interface RunFlags {
|
|
141
|
+
readonly file?: string;
|
|
142
|
+
readonly evalSource?: string;
|
|
143
|
+
readonly evalFd?: number;
|
|
144
|
+
readonly eventsFd?: number;
|
|
145
|
+
readonly record?: string;
|
|
146
|
+
readonly replay?: string;
|
|
147
|
+
readonly resume?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Names the first pair of options that cannot appear in one invocation. */
|
|
151
|
+
function conflict(flags: RunFlags): string | null {
|
|
152
|
+
const { file, evalSource, evalFd, eventsFd, record, replay, resume } = flags;
|
|
153
|
+
if (file !== undefined && evalSource !== undefined) {
|
|
154
|
+
return "--eval and a program file cannot be used together";
|
|
155
|
+
}
|
|
156
|
+
if (evalSource !== undefined && evalFd !== undefined) {
|
|
157
|
+
return "--eval and --eval-fd cannot be used together";
|
|
158
|
+
}
|
|
159
|
+
if (file !== undefined && evalFd !== undefined) {
|
|
160
|
+
return "--eval-fd and a program file cannot be used together";
|
|
161
|
+
}
|
|
162
|
+
if (evalFd !== undefined && eventsFd !== undefined && evalFd === eventsFd) {
|
|
163
|
+
return "--eval-fd and --events-fd must use different descriptors";
|
|
164
|
+
}
|
|
165
|
+
if (record !== undefined && replay !== undefined) {
|
|
166
|
+
return "--record and --replay cannot be used together";
|
|
167
|
+
}
|
|
168
|
+
if (resume !== undefined && replay !== undefined) {
|
|
169
|
+
return "--resume and --replay cannot be used together";
|
|
170
|
+
}
|
|
171
|
+
if (resume !== undefined && record !== undefined && resolve(resume) === resolve(record)) {
|
|
172
|
+
return "--resume and --record must use different paths";
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A Cassette is history, never an execution source, so the caller gives the program (ADR-0033). */
|
|
178
|
+
function missingProgram(
|
|
179
|
+
resume: string | undefined,
|
|
180
|
+
replay: string | undefined,
|
|
181
|
+
): { readonly ok: false; readonly error: string } {
|
|
182
|
+
if (resume === undefined && replay === undefined) return failure(USAGE);
|
|
183
|
+
const flag = resume !== undefined ? "--resume" : "--replay";
|
|
184
|
+
return failure(
|
|
185
|
+
`${flag} needs the program too: give <program.ts>, --eval <source>, or --eval-fd <n>. ` +
|
|
186
|
+
`A cassette holds the history of a Run, and never the program to run.\n${USAGE}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function programSource(
|
|
191
|
+
file: string | undefined,
|
|
192
|
+
source: string | undefined,
|
|
193
|
+
fd: number | undefined,
|
|
194
|
+
): ProgramSource | undefined {
|
|
195
|
+
if (source !== undefined) return { kind: "inline", source };
|
|
196
|
+
if (fd !== undefined) return { kind: "inline-fd", fd };
|
|
197
|
+
return file === undefined ? undefined : { kind: "file", file };
|
|
198
|
+
}
|
|
199
|
+
|
|
115
200
|
function parseDescribe(tokens: readonly string[]): ParsedArgv {
|
|
116
201
|
if (tokens.length !== 1 || tokens[0]?.startsWith("-")) return failure(USAGE);
|
|
117
202
|
return { ok: true, command: "describe", file: tokens[0] };
|
|
@@ -142,6 +227,14 @@ function parseDescriptor(value: string): ParsedValue<number> {
|
|
|
142
227
|
: failure(`--events-fd must be a descriptor number\n${USAGE}`);
|
|
143
228
|
}
|
|
144
229
|
|
|
230
|
+
/** 0, 1 and 2 are stdin, stdout and stderr, so an Inline Program needs 3 or higher. */
|
|
231
|
+
function parseEvalDescriptor(value: string): ParsedValue<number> {
|
|
232
|
+
const fd = Number(value);
|
|
233
|
+
return Number.isInteger(fd) && fd >= 3
|
|
234
|
+
? { ok: true, value: fd }
|
|
235
|
+
: failure(`--eval-fd must be a descriptor number of 3 or higher\n${USAGE}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
145
238
|
function failure(error: string): { readonly ok: false; readonly error: string } {
|
|
146
239
|
return { ok: false, error };
|
|
147
240
|
}
|
package/src/cli-tree-host.ts
CHANGED
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
* caller adds `stop` and `done`: nothing in this module can prompt
|
|
7
7
|
* an Agent (ADR — a Peek observes, it never sends).
|
|
8
8
|
*/
|
|
9
|
-
import { readFile
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
12
10
|
import type { Terminal } from "@earendil-works/pi-tui";
|
|
13
11
|
import type { AgentInfo } from "@yaag/runtime";
|
|
14
12
|
import { cliKeybindings, type RunTreeViewHost, type TreeState } from "@yaag/tui";
|
|
@@ -21,8 +19,6 @@ export interface CliTreeHostOptions {
|
|
|
21
19
|
/** Shows a transient message; `tree-app.ts` routes it to the TUI. */
|
|
22
20
|
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
23
21
|
requestRender(): void;
|
|
24
|
-
/** Where `openEditor` puts the body; defaults to the system temp directory. */
|
|
25
|
-
readonly scratchDir?: string;
|
|
26
22
|
}
|
|
27
23
|
|
|
28
24
|
/** The read-only capabilities the CLI Run view has. */
|
|
@@ -41,17 +37,6 @@ export function createCliTreeHost(options: CliTreeHostOptions): ReadOnlyCliHost
|
|
|
41
37
|
copyPath: async (text) => {
|
|
42
38
|
terminal.write(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
|
|
43
39
|
},
|
|
44
|
-
// Spawning $EDITOR inside a live alt-screen would fight the TUI for the
|
|
45
|
-
// terminal, so the body lands in a file and the path is reported instead.
|
|
46
|
-
openEditor: async (title, body) => {
|
|
47
|
-
const path = join(options.scratchDir ?? tmpdir(), scratchName(title));
|
|
48
|
-
try {
|
|
49
|
-
await writeFile(path, body, "utf8");
|
|
50
|
-
options.notify(`Wrote ${title} to ${path}`, "info");
|
|
51
|
-
} catch (error) {
|
|
52
|
-
options.notify(`Could not write ${title}: ${message(error)}`, "error");
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
40
|
notify: (text, level) => options.notify(text, level),
|
|
56
41
|
requestRender: () => options.requestRender(),
|
|
57
42
|
rows: () => Math.max(1, terminal.rows - 1),
|
|
@@ -70,12 +55,3 @@ async function readSession(info: AgentInfo | undefined): Promise<string | null>
|
|
|
70
55
|
return null;
|
|
71
56
|
}
|
|
72
57
|
}
|
|
73
|
-
|
|
74
|
-
function scratchName(title: string): string {
|
|
75
|
-
const slug = title.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
76
|
-
return `yaag-${slug === "" ? "note" : slug}.txt`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function message(error: unknown): string {
|
|
80
|
-
return error instanceof Error ? error.message : String(error);
|
|
81
|
-
}
|
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
assertReplayable,
|
|
5
5
|
executeRun,
|
|
6
|
-
isOrchestrationProgram,
|
|
7
6
|
isYaagError,
|
|
8
7
|
loadCassette,
|
|
9
8
|
type OrchestrationProgram,
|
|
@@ -15,7 +14,9 @@ import { parseArgv } from "./argv.ts";
|
|
|
15
14
|
import { runInteractive } from "./interactive-run.ts";
|
|
16
15
|
import { writeChannel, writeChannelFd } from "./output-channel.ts";
|
|
17
16
|
import { createPlainPresenter } from "./presenter.ts";
|
|
17
|
+
import { loadProgram } from "./program-loader.ts";
|
|
18
18
|
import { executeOptions, formatResult, type RunFlags } from "./run-invocation.ts";
|
|
19
|
+
import { loadRunProgram } from "./run-program.ts";
|
|
19
20
|
import { registerRuntimeAlias } from "./runtime-alias.ts";
|
|
20
21
|
import { setupWorkspace } from "./setup-workspace.ts";
|
|
21
22
|
|
|
@@ -42,11 +43,11 @@ export async function main(argv: readonly string[]): Promise<number> {
|
|
|
42
43
|
if (parsed.command === "run" && parsed.resume !== undefined) {
|
|
43
44
|
await loadCassette(parsed.resume);
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
const program = await
|
|
47
|
-
if (parsed.command === "describe") return describe(program);
|
|
46
|
+
if (parsed.command === "describe") return describe(await loadProgram(resolve(parsed.file)));
|
|
47
|
+
const { program, programFile, programSource } = await loadRunProgram(parsed.program);
|
|
48
48
|
return await run(program, {
|
|
49
49
|
programFile,
|
|
50
|
+
programSource,
|
|
50
51
|
args: parsed.args,
|
|
51
52
|
eventsFd: parsed.eventsFd,
|
|
52
53
|
record: parsed.record,
|
|
@@ -142,19 +143,6 @@ function eventSink(fd: number | undefined, present: StampedEventSink): StampedEv
|
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
/** The program is the module's default export, and nothing else will do. */
|
|
146
|
-
async function loadProgram(path: string): Promise<OrchestrationProgram> {
|
|
147
|
-
const module: unknown = await import(path);
|
|
148
|
-
const program =
|
|
149
|
-
typeof module === "object" && module !== null && "default" in module
|
|
150
|
-
? module.default
|
|
151
|
-
: undefined;
|
|
152
|
-
if (!isOrchestrationProgram(program)) {
|
|
153
|
-
throw new Error(`${path}: export defineRun({ run }) as the default export`);
|
|
154
|
-
}
|
|
155
|
-
return program;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
146
|
if (import.meta.main) {
|
|
159
147
|
const code = await main(process.argv.slice(2));
|
|
160
148
|
// Exit rather than wait for the loop to drain: a stopped Run may leave the
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds a dynamic `import(...)` whose specifier is not one plain string
|
|
3
|
+
* literal.
|
|
4
|
+
*
|
|
5
|
+
* The specifier of such a call only exists while the program runs, so a scan
|
|
6
|
+
* of the literal specifiers cannot see it, and Bun resolves a built-in module
|
|
7
|
+
* and an absolute path without asking a resolver plugin. The call is refused
|
|
8
|
+
* as syntax, before the source becomes a module (ADR-0033). The `require`
|
|
9
|
+
* binding needs no scan here: `inline-program.ts` replaces it when the module
|
|
10
|
+
* loads, so every `require` call is checked when it runs.
|
|
11
|
+
*
|
|
12
|
+
* The scan reads the raw text and does not tokenize. `import` is a reserved
|
|
13
|
+
* word: real code cannot write it with escape sequences, cannot split it, and
|
|
14
|
+
* cannot put an identifier character or a `.` directly before a dynamic
|
|
15
|
+
* import. So each dynamic import in real code is an occurrence of the word
|
|
16
|
+
* `import` in the raw text, and a check of every occurrence misses none — no
|
|
17
|
+
* reading of a comment, string, or regular expression can hide one. The cost
|
|
18
|
+
* is the reverse case: text that only looks like a computed dynamic import,
|
|
19
|
+
* for example in a prompt string, is also refused. That refusal is safe, and
|
|
20
|
+
* the caller fixes it by making the program a file.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One `import(...)` shape whose specifier is not one plain string literal. */
|
|
24
|
+
export interface ComputedImport {
|
|
25
|
+
/** 1-based line of the `import` word in the scanned source. */
|
|
26
|
+
readonly line: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Returns the first computed dynamic `import(...)` shape, or `undefined`. */
|
|
30
|
+
export function findComputedImport(source: string): ComputedImport | undefined {
|
|
31
|
+
for (let from = 0; ; ) {
|
|
32
|
+
const at = source.indexOf("import", from);
|
|
33
|
+
if (at === -1) return undefined;
|
|
34
|
+
from = at + "import".length;
|
|
35
|
+
if (isWordChar(source[at - 1]) || source[at - 1] === "." || isWordChar(source[from])) continue;
|
|
36
|
+
const open = skipTrivia(source, from);
|
|
37
|
+
if (source[open] !== "(") continue;
|
|
38
|
+
if (!takesOneStringLiteral(source, open + 1)) return { line: lineAt(source, at) };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True when the argument list that starts at `from` opens with one plain
|
|
44
|
+
* string literal, followed by `)` or by a `,` and more arguments.
|
|
45
|
+
*
|
|
46
|
+
* When the occurrence is real code, the trivia around the literal is real
|
|
47
|
+
* trivia, so this reads exactly what Bun reads. When the occurrence is inert
|
|
48
|
+
* text, a wrong reading can only refuse a harmless program.
|
|
49
|
+
*/
|
|
50
|
+
function takesOneStringLiteral(source: string, from: number): boolean {
|
|
51
|
+
const start = skipTrivia(source, from);
|
|
52
|
+
const quote = source[start];
|
|
53
|
+
if (quote !== '"' && quote !== "'") return false;
|
|
54
|
+
const end = endOfQuoted(source, start);
|
|
55
|
+
if (end === undefined) return false;
|
|
56
|
+
const next = source[skipTrivia(source, end)];
|
|
57
|
+
return next === ")" || next === ",";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True for a character that can continue an identifier. */
|
|
61
|
+
function isWordChar(char: string | undefined): boolean {
|
|
62
|
+
return char !== undefined && /[\w$]/.test(char);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The index after the whitespace and comments that start at `index`. */
|
|
66
|
+
function skipTrivia(source: string, index: number): number {
|
|
67
|
+
let cursor = index;
|
|
68
|
+
for (;;) {
|
|
69
|
+
while (/\s/.test(source[cursor] ?? "")) cursor += 1;
|
|
70
|
+
if (source[cursor] === "/" && source[cursor + 1] === "/") {
|
|
71
|
+
const line = source.indexOf("\n", cursor);
|
|
72
|
+
cursor = line === -1 ? source.length : line + 1;
|
|
73
|
+
} else if (source[cursor] === "/" && source[cursor + 1] === "*") {
|
|
74
|
+
const end = source.indexOf("*/", cursor + 2);
|
|
75
|
+
cursor = end === -1 ? source.length : end + 2;
|
|
76
|
+
} else {
|
|
77
|
+
return cursor;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The index after a `'` or `"` string, or `undefined` when the line ends first. */
|
|
83
|
+
function endOfQuoted(source: string, start: number): number | undefined {
|
|
84
|
+
const quote = source[start];
|
|
85
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
86
|
+
const char = source[index];
|
|
87
|
+
if (char === "\\") {
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (char === "\n") return undefined;
|
|
92
|
+
if (char === quote) return index + 1;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function lineAt(source: string, index: number): number {
|
|
98
|
+
let line = 1;
|
|
99
|
+
for (let cursor = 0; cursor < index; cursor += 1) if (source[cursor] === "\n") line += 1;
|
|
100
|
+
return line;
|
|
101
|
+
}
|
package/src/eval-fd.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the source of an Inline Program from a file descriptor (ADR-0033).
|
|
3
|
+
*
|
|
4
|
+
* An argv element is readable by every local user through `ps` and
|
|
5
|
+
* `/proc/<pid>/cmdline`, so the source of an Inline Program travels on a
|
|
6
|
+
* descriptor instead. Descriptor 3 stays exclusive to Run Lifecycle Events
|
|
7
|
+
* (ADR-0016), so the extension writes the source to descriptor 4.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The descriptor the extension writes an Inline Program to; fd 3 is events. */
|
|
11
|
+
export const DEFAULT_EVAL_FD = 4;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads the whole Inline Program source from `fd`, until end of file.
|
|
15
|
+
*
|
|
16
|
+
* The read stops when the writer closes the descriptor, so a caller that
|
|
17
|
+
* keeps the descriptor open parks the Run before the program loads.
|
|
18
|
+
*
|
|
19
|
+
* Throws an `Error` that names the descriptor when the read fails, and when
|
|
20
|
+
* the descriptor gives no source.
|
|
21
|
+
*/
|
|
22
|
+
export async function readEvalSource(fd: number): Promise<string> {
|
|
23
|
+
let source: string;
|
|
24
|
+
try {
|
|
25
|
+
source = await Bun.file(fd).text();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(`--eval-fd ${fd} cannot be read: ${String(error)}`);
|
|
28
|
+
}
|
|
29
|
+
if (source === "") {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`--eval-fd ${fd} gave no source; write the Orchestration Program to the descriptor and close it`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return source;
|
|
35
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed import contract of an Inline Program (ADR-0033).
|
|
3
|
+
*
|
|
4
|
+
* An Inline Program can import `@yaag/runtime` and `typebox` only. Placement
|
|
5
|
+
* in `os.tmpdir()` stops relative and project-package resolution, but it stops
|
|
6
|
+
* neither a built-in module such as `node:fs` nor an absolute path, and
|
|
7
|
+
* removal of the module file stops neither one either. Three checks close the
|
|
8
|
+
* contract. Before the source becomes a module: each literal specifier that
|
|
9
|
+
* Bun's parser reports must be allowed, and a dynamic `import(...)` whose
|
|
10
|
+
* specifier is not one plain string literal is refused as syntax. While the
|
|
11
|
+
* module runs: `inlineRequirePrelude` replaces the `require` binding and
|
|
12
|
+
* `import.meta.require`, so every `require` call — direct, aliased, or
|
|
13
|
+
* computed — is checked with its real specifier when it runs.
|
|
14
|
+
*/
|
|
15
|
+
import { findComputedImport } from "./computed-imports.ts";
|
|
16
|
+
|
|
17
|
+
/** The only specifiers an Inline Program can import. */
|
|
18
|
+
export const ALLOWED_INLINE_IMPORTS: readonly string[] = ["@yaag/runtime", "typebox"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rejects an Inline Program that imports outside the closed contract.
|
|
22
|
+
*
|
|
23
|
+
* Refuses a dynamic `import(...)` whose specifier is not one plain string
|
|
24
|
+
* literal, then reads each literal specifier — static import, export-from,
|
|
25
|
+
* `require` call, and dynamic `import()` — and refuses one that is not
|
|
26
|
+
* allowed. Throws `Error` on the first refusal, and on source text that does
|
|
27
|
+
* not parse. The refusal happens before the source becomes a module, so no
|
|
28
|
+
* disallowed module is loaded. A `require` call that computes its specifier
|
|
29
|
+
* passes this check; `inlineRequirePrelude` refuses it when it runs, before
|
|
30
|
+
* any module resolves.
|
|
31
|
+
*/
|
|
32
|
+
export function assertClosedImports(source: string): void {
|
|
33
|
+
const computed = findComputedImport(source);
|
|
34
|
+
if (computed !== undefined) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`inline program cannot import a computed specifier ` +
|
|
37
|
+
`(import call on line ${computed.line}): ` +
|
|
38
|
+
`an Inline Program allows ${allowedText()} only, each one as a string literal`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const transpiler = new Bun.Transpiler({ loader: "ts" });
|
|
42
|
+
for (const scanned of transpiler.scanImports(source)) {
|
|
43
|
+
if (ALLOWED_INLINE_IMPORTS.includes(scanned.path)) continue;
|
|
44
|
+
throw new Error(
|
|
45
|
+
`inline program cannot import ${JSON.stringify(scanned.path)}: ` +
|
|
46
|
+
`an Inline Program allows ${allowedText()} only`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One line of code that `inline-program.ts` puts before an Inline Program.
|
|
53
|
+
*
|
|
54
|
+
* The line shadows the module-scope `require` binding with a wrapper that
|
|
55
|
+
* refuses every specifier outside `ALLOWED_INLINE_IMPORTS`, and puts the same
|
|
56
|
+
* wrapper on `import.meta.require`. Every `require` call — direct, aliased,
|
|
57
|
+
* stored in a variable, written with identifier escape sequences, or reached
|
|
58
|
+
* through `import.meta` — runs the wrapper, so this check does not depend on
|
|
59
|
+
* a reading of the source. An allowed specifier goes to the real `require`
|
|
60
|
+
* unchanged. The line holds no line break, so it moves each source line down
|
|
61
|
+
* by exactly one.
|
|
62
|
+
*/
|
|
63
|
+
export function inlineRequirePrelude(): string {
|
|
64
|
+
const suffix = JSON.stringify(`: an Inline Program allows ${allowedText()} only`);
|
|
65
|
+
return [
|
|
66
|
+
"const require = ((real, allowed) => (id) => {",
|
|
67
|
+
" if (allowed.includes(id)) return real(id);",
|
|
68
|
+
` throw new Error("inline program cannot require " + JSON.stringify(id) + ${suffix});`,
|
|
69
|
+
` })(import.meta.require, ${JSON.stringify(ALLOWED_INLINE_IMPORTS)});`,
|
|
70
|
+
" import.meta.require = require;",
|
|
71
|
+
].join("");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function allowedText(): string {
|
|
75
|
+
return ALLOWED_INLINE_IMPORTS.map((name) => JSON.stringify(name)).join(" and ");
|
|
76
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads an Inline Program: source text that a caller gives instead of a file.
|
|
3
|
+
*
|
|
4
|
+
* The source goes to a temporary `.ts` file in `os.tmpdir()`, through the same
|
|
5
|
+
* loader a file program uses, and the file is unlinked whether the import
|
|
6
|
+
* succeeds or throws. A real file gives real module resolution and real stack
|
|
7
|
+
* traces. The import contract is closed (ADR-0033): before the source becomes
|
|
8
|
+
* a module, `inline-imports.ts` rejects each literal specifier other than
|
|
9
|
+
* `@yaag/runtime` and `typebox` and each computed `import(...)`; while the
|
|
10
|
+
* module runs, a one-line prelude before the source checks every `require`
|
|
11
|
+
* call with its real specifier.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { unlink, writeFile } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { OrchestrationProgram } from "@yaag/runtime";
|
|
18
|
+
import { assertClosedImports, inlineRequirePrelude } from "./inline-imports.ts";
|
|
19
|
+
import { loadProgram } from "./program-loader.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Writes the source to a temporary module, imports it, and removes the file.
|
|
23
|
+
*
|
|
24
|
+
* Throws `Error` when the source imports outside the closed contract, and
|
|
25
|
+
* whatever `writeFile` throws when `os.tmpdir()` cannot take the file. Each
|
|
26
|
+
* import and validation failure of `loadProgram` passes through unchanged, so
|
|
27
|
+
* the guidance is the guidance a bad file gives. The temporary file is
|
|
28
|
+
* unlinked on each path, and a failed unlink never replaces the primary error.
|
|
29
|
+
* Removal of the file does not keep a running program from resolving a
|
|
30
|
+
* built-in module or an absolute path, so the contract is enforced by the
|
|
31
|
+
* check on the source and by the `require` prelude, not by the removal. The
|
|
32
|
+
* prelude is one line before the source, so each stack line the module
|
|
33
|
+
* reports is one below the line the caller wrote; a source that declares its
|
|
34
|
+
* own top-level `require` binding fails to load.
|
|
35
|
+
*/
|
|
36
|
+
export async function loadInlineProgram(source: string): Promise<OrchestrationProgram> {
|
|
37
|
+
assertClosedImports(source);
|
|
38
|
+
// A unique name per Run keeps Bun's module cache from aliasing two different
|
|
39
|
+
// Inline Programs when `main()` runs more than one time in one process.
|
|
40
|
+
const path = join(tmpdir(), `yaag-inline-${randomUUID()}.ts`);
|
|
41
|
+
await writeFile(path, `${inlineRequirePrelude()}\n${source}`, { mode: 0o600 });
|
|
42
|
+
try {
|
|
43
|
+
return await loadProgram(path);
|
|
44
|
+
} finally {
|
|
45
|
+
// The program's own error is the primary diagnostic, so a failed unlink
|
|
46
|
+
// must not replace it.
|
|
47
|
+
await unlink(path).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads an Orchestration Program module from a file.
|
|
3
|
+
*
|
|
4
|
+
* One loader keeps a file program and an Inline Program on identical guidance
|
|
5
|
+
* when a module is not a program.
|
|
6
|
+
*/
|
|
7
|
+
import { isOrchestrationProgram, type OrchestrationProgram } from "@yaag/runtime";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Imports `path` and returns its default export as an Orchestration Program.
|
|
11
|
+
*
|
|
12
|
+
* The program is the module's default export, and nothing else will do.
|
|
13
|
+
* Throws what Bun throws when the module is missing, does not parse, resolves
|
|
14
|
+
* no import, or throws at its top level. Throws `Error` with the guidance
|
|
15
|
+
* `export defineRun({ run }) as the default export` when the module imports
|
|
16
|
+
* but its default export is not an Orchestration Program.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadProgram(path: string): Promise<OrchestrationProgram> {
|
|
19
|
+
const module: unknown = await import(path);
|
|
20
|
+
const program =
|
|
21
|
+
typeof module === "object" && module !== null && "default" in module
|
|
22
|
+
? module.default
|
|
23
|
+
: undefined;
|
|
24
|
+
if (!isOrchestrationProgram(program)) {
|
|
25
|
+
throw new Error(`${path}: export defineRun({ run }) as the default export`);
|
|
26
|
+
}
|
|
27
|
+
return program;
|
|
28
|
+
}
|
package/src/run-invocation.ts
CHANGED
|
@@ -9,7 +9,10 @@ import type { RunOptions, StampedEventSink } from "@yaag/runtime";
|
|
|
9
9
|
|
|
10
10
|
/** Everything `yaag run` parsed for one Run. */
|
|
11
11
|
export interface RunFlags {
|
|
12
|
-
|
|
12
|
+
/** Absent for an Inline Program, which has no file to record (ADR-0033). */
|
|
13
|
+
readonly programFile: string | undefined;
|
|
14
|
+
/** Present only for an Inline Program; becomes the Run's recorded identity (ADR-0033). */
|
|
15
|
+
readonly programSource: string | undefined;
|
|
13
16
|
readonly args: unknown;
|
|
14
17
|
readonly eventsFd: number | undefined;
|
|
15
18
|
readonly record: string | undefined;
|
|
@@ -27,7 +30,8 @@ export function executeOptions(
|
|
|
27
30
|
return {
|
|
28
31
|
events,
|
|
29
32
|
args: flags.args,
|
|
30
|
-
programFile: flags.programFile,
|
|
33
|
+
...(flags.programFile === undefined ? {} : { programFile: flags.programFile }),
|
|
34
|
+
...(flags.programSource === undefined ? {} : { programSource: flags.programSource }),
|
|
31
35
|
...(flags.record === undefined ? {} : { record: flags.record }),
|
|
32
36
|
...(flags.replay === undefined ? {} : { replay: flags.replay }),
|
|
33
37
|
...(flags.resume === undefined ? {} : { resume: flags.resume }),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composes the ways `yaag run` gets its Orchestration Program: a file, or the
|
|
3
|
+
* source of an Inline Program. That source arrives as text, or on a
|
|
4
|
+
* descriptor (ADR-0033); the loader below does not know which.
|
|
5
|
+
*
|
|
6
|
+
* The composition sits above both loaders, so neither loader knows the other
|
|
7
|
+
* and the imports stay a DAG.
|
|
8
|
+
*/
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import type { OrchestrationProgram } from "@yaag/runtime";
|
|
11
|
+
import type { ProgramSource } from "./argv.ts";
|
|
12
|
+
import { readEvalSource } from "./eval-fd.ts";
|
|
13
|
+
import { loadInlineProgram } from "./inline-program.ts";
|
|
14
|
+
import { loadProgram } from "./program-loader.ts";
|
|
15
|
+
|
|
16
|
+
/** What one Run loaded. An Inline Program has no program file (ADR-0033). */
|
|
17
|
+
export interface LoadedProgram {
|
|
18
|
+
readonly program: OrchestrationProgram;
|
|
19
|
+
readonly programFile: string | undefined;
|
|
20
|
+
/** Present only for an Inline Program: its source is the Run's identity (ADR-0033). */
|
|
21
|
+
readonly programSource: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Loads the program a `run` invocation names, from a file or from source text.
|
|
26
|
+
*
|
|
27
|
+
* A file path is resolved against the working directory, and its
|
|
28
|
+
* `programFile` is that absolute path. An Inline Program reports no
|
|
29
|
+
* `programFile`, because its temporary module is unlinked after the import; it
|
|
30
|
+
* reports its source text as its identity instead. A `--eval-fd` program is
|
|
31
|
+
* read to end of file first, so a writer that never closes the descriptor
|
|
32
|
+
* parks the Run (see `eval-fd.ts`).
|
|
33
|
+
* Each failure of `loadProgram` or `loadInlineProgram` — a missing file, a
|
|
34
|
+
* specifier outside the closed contract, a module that is not a program —
|
|
35
|
+
* passes through unchanged.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadRunProgram(source: ProgramSource): Promise<LoadedProgram> {
|
|
38
|
+
switch (source.kind) {
|
|
39
|
+
case "file": {
|
|
40
|
+
const programFile = resolve(source.file);
|
|
41
|
+
return { program: await loadProgram(programFile), programFile, programSource: undefined };
|
|
42
|
+
}
|
|
43
|
+
case "inline":
|
|
44
|
+
return {
|
|
45
|
+
program: await loadInlineProgram(source.source),
|
|
46
|
+
programFile: undefined,
|
|
47
|
+
programSource: source.source,
|
|
48
|
+
};
|
|
49
|
+
case "inline-fd": {
|
|
50
|
+
const text = await readEvalSource(source.fd);
|
|
51
|
+
return {
|
|
52
|
+
program: await loadInlineProgram(text),
|
|
53
|
+
programFile: undefined,
|
|
54
|
+
programSource: text,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|