Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cli/build-run-args.ts → cli/commands/build-run-args.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ExecOptions } from "@effectionx/process";
import process from "node:process";
import type { RunConfig } from "./config.ts";
import type { RunType } from "./run.ts";

export type Runtime = "node" | "deno" | "bun";

Expand All @@ -12,7 +12,7 @@ function hasLoaderSpecified(packageName: string) {

export function buildProcessOptions(
runtime: Runtime,
config: RunConfig,
config: RunType["config"],
passthroughArgs: string[],
): ExecOptions {
let env = { ...process.env } as Record<string, string>;
Expand Down Expand Up @@ -104,7 +104,7 @@ export function buildProcessOptions(
* hence the default is still "node". If the executable name contains
* "deno" or "bun" we return the corresponding runtime.
*/
export function resolveRuntime(config: RunConfig): Runtime {
export function resolveRuntime(config: RunType["config"]): Runtime {
if (config.inspectRuntime) {
return config.inspectRuntime as Runtime;
}
Expand Down
43 changes: 43 additions & 0 deletions cli/commands/call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { until } from "effection";
import { writeFile } from "node:fs/promises";
import { createSSEClient } from "../../lib/sse-client.ts";
import { log } from "../logger.ts";
import type { Program } from "../config.ts";
import type { CommandType } from "configliere";

// We need better helper types to destructure config
export type CallType = Extract<
Extract<CommandType<ReturnType<Program>, "call">, { help?: false }>["config"],
{ help?: false }
>["config"];

export function* call(config: CallType) {
const { name, out, host, protocol } = config;

const argsList = [] as never[];
const handle = createSSEClient(protocol, { url: host });
if (!(name in handle.protocol.methods)) {
yield* log.error(`unknown command: ${name}`);
}
let results: unknown[] = [];
let subscription = yield* handle.invoke({
name: name as any,
args: argsList,
});
let next = yield* subscription.next();
// log progress values and collect everything, including final return
while (!next.done) {
results.push(next.value);
yield* log.info(JSON.stringify(next.value));
next = yield* subscription.next();
}

if (out) {
try {
yield* until(writeFile(out, JSON.stringify(results, null, 2)));
} catch (e) {
let msg = e instanceof Error ? e.message : String(e);
yield* log.error(`failed to write ${out}: ${msg}`);
}
}
}
89 changes: 89 additions & 0 deletions cli/commands/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { type Operation, each, resource, sleep, spawn, until, withResolvers } from "effection";
import { exec } from "@effectionx/process";
import { writeFile } from "node:fs/promises";
import { createSSEClient } from "../../lib/sse-client.ts";
import { log } from "../logger.ts";
import { resolveRuntime, buildProcessOptions } from "./build-run-args.ts";
import type { Protocol } from "../../lib/types.ts";
import type { CommandType } from "configliere";
import type { Program } from "../config.ts";

// We need better helper types to destructure config
export type RunType = Extract<CommandType<ReturnType<Program>, "run">, { help?: false }>;

export function run(config: RunType["config"], passthroughArgs: string[]) {
return resource<number | undefined>(function* (provide) {
try {
let runtime = resolveRuntime(config);
let host = `http://localhost:${config.inspectPort}`;

let processOptions = buildProcessOptions(runtime, config, passthroughArgs);

let ready = withResolvers<void>();
let childSpawned = withResolvers<void>();

yield* spawn(function* () {
yield* childSpawned.operation;
for (let chunk of yield* each(child.stdout)) {
yield* log.info(chunk.toString());
yield* each.next();
}
});

yield* spawn(function* () {
yield* childSpawned.operation;
for (let chunk of yield* each(child.stderr)) {
if (chunk.toString().includes("effection inspector")) {
ready.resolve();
}
yield* log.error(chunk.toString());
yield* each.next();
}
});

yield* spawn(function* () {
yield* ready.operation;
if (config.inspectRecord) {
yield* recordNodeMapToFile(host, config.inspectRecord, config.protocol);
}
});
let child = yield* exec(runtime, processOptions);
childSpawned.resolve();

yield* spawn(function* () {
yield* sleep(15000);
ready.reject(new Error("timeout waiting for program to start"));
});

let status = yield* child.join();

yield* provide(status.code);
} finally {
// runProgram() exiting
}
});
}

function* recordNodeMapToFile(
host: string,
filePath: string,
protocol: Protocol<any>,
): Operation<void> {
let handle = createSSEClient(protocol, { url: host });
let values: unknown[] = [];
try {
let subscription = yield* handle.invoke({ name: "recordNodeMap", args: [] });

let next = yield* subscription.next();
while (!next.done) {
values.push(next.value);
next = yield* subscription.next();
}
} catch (error) {
let message = error instanceof Error ? error.message : String(error);
yield* log.error(`record NodeMap interrupted: ${message}`);
} finally {
// always attempt to write whatever we have collected (possibly empty)
yield* until(writeFile(filePath, JSON.stringify(values, null, 2)));
}
}
196 changes: 94 additions & 102 deletions cli/config.ts
Original file line number Diff line number Diff line change
@@ -1,112 +1,104 @@
import { commands, field, object, program, help } from "configliere";
import { commands, field, object, program, inject, type ProgramType, constant } from "configliere";
import packageJSON from "../package.json" with { type: "json" };
import { type } from "arktype";
import { scope, player } from "../lib/protocols.ts";
import { combine } from "../mod.ts";
import type { ParsedConfig } from "./types.ts";

export const inspector = combine.protocols(scope.protocol, player.protocol);

const commandBase = object({
out: {
description: "write out the response to a file",
...field(type("string | undefined"), field.default(undefined)),
},
host: {
description: "inspector base URL (overrides default)",
...field(type("string"), field.default("http://localhost:41000")),
},
});
type InspectorProtocolCommandBase = Record<
keyof typeof inspector.methods,
{ description: string } & typeof commandBase
>;
const inspectorProtocolEntries = (
Object.keys(inspector.methods) as (keyof typeof inspector.methods)[]
).reduce((base, current) => {
base[current] = {
description: `/${current} API`,
...commandBase,
};
return base satisfies InspectorProtocolCommandBase;
}, {} as InspectorProtocolCommandBase);

const protocolCommands = commands(inspectorProtocolEntries);

export type ProtocolCommandConfig = ParsedConfig<typeof protocolCommands>;

const runBase = object({
// TODO this throws an error if we have a remainder of more than one arg,
// which is a problem for the `run` command since we want to support passing
// through args to the program being run.
// entrypoint: {
// description: "entrypoint file",
// ...field(type("string"), cli.argument()),
// },
inspectRecord: {
description: "write inspector recording to the given file",
...field(type("string | undefined"), field.default(undefined)),
},
inspectRuntime: {
description:
"which JavaScript runtime to launch (node, deno, bun).\n" +
"If omitted we infer from the executable that invoked the CLI",
...field(type("'node'|'deno'|'bun'"), field.default("node")),
},
inspectPause: {
description: "start program paused until resumed by inspector",
...field(type("boolean"), field.default(false)),
},
inspectPort: {
description: "port number to give to the inspector loader",
...field(type("number"), field.default(41000)),
},
inspectPackage: {
description: "package spec to preload/import/require (defaults to @effectionx/inspector)",
...field(type("string"), field.default("@effectionx/inspector")),
},
import: {
description: "tracking loader passed in from the user",
...field(type("string[]"), field.array(), field.default([])),
},
preload: {
description: "tracking loader passed in from the user",
...field(type("string[]"), field.array(), field.default([])),
},
require: {
description: "tracking loader passed in from the user",
aliases: ["-r"],
...field(type("string[]"), field.array(), field.default([])),
},
});
import { type Methods, type Protocol } from "../mod.ts";

export const config = program({
name: "inspector",
version: packageJSON.version,
config: commands(
{
help,
ui: {
description: "start up the inspector UI",
...object({
inspectPort: {
description: "port number to give to the inspector loader",
...field(type("number"), field.default(41000)),
config: inject((protocol: Protocol<Methods>) => {
let methods = commands(
Object.fromEntries(
Object.keys(protocol.methods).map((name) => [
name,
{
description: `invoke method ${name}`,
...object({
name: constant(name),
protocol: constant(protocol),
out: {
description: "write out the response to a file",
...field(type("string | undefined"), field.default(undefined)),
},
host: {
description: "inspector base URL (overrides default)",
...field(type("string"), field.default("http://localhost:41000")),
},
}),
},
}),
},
call: {
description: "invoke a inspector protocol method directly (low level)",
aliases: ["c"],
...protocolCommands,
},
run: {
description: "inspect a CLI program",
...runBase,
]),
),
);

return commands(
{
ui: {
description: "start up the inspector UI",
...object({
inspectPort: {
description: "port number to give to the inspector loader",
...field(type("number"), field.default(41000)),
},
}),
},
call: {
description: "invoke a inspector protocol method directly (low level)",
aliases: ["c"],
...methods,
},
run: {
description: "inspect a CLI program",
...object({
// TODO this throws an error if we have a remainder of more than one arg,
// which is a problem for the `run` command since we want to support passing
// through args to the program being run.
// entrypoint: {
// description: "entrypoint file",
// ...field(type("string"), cli.argument()),
// },
protocol: constant(protocol),
inspectRecord: {
description: "write inspector recording to the given file",
...field(type("string | undefined"), field.default(undefined)),
},
inspectRuntime: {
description:
"which JavaScript runtime to launch (node, deno, bun).\n" +
"If omitted we infer from the executable that invoked the CLI",
...field(type("'node'|'deno'|'bun'"), field.default("node")),
},
inspectPause: {
description: "start program paused until resumed by inspector",
...field(type("boolean"), field.default(false)),
},
inspectPort: {
description: "port number to give to the inspector loader",
...field(type("number"), field.default(41000)),
},
inspectPackage: {
description:
"package spec to preload/import/require (defaults to @effectionx/inspector)",
...field(type("string"), field.default("@effectionx/inspector")),
},
import: {
description: "tracking loader passed in from the user",
...field(type("string[]"), field.array(), field.default([])),
},
preload: {
description: "tracking loader passed in from the user",
...field(type("string[]"), field.array(), field.default([])),
},
require: {
description: "tracking loader passed in from the user",
aliases: ["-r"],
...field(type("string[]"), field.array(), field.default([])),
},
}),
},
},
},
{ default: "run" },
),
{ default: "run" },
);
}),
});

export type RunConfig = ParsedConfig<typeof runBase>;
export type Program = ProgramType<typeof config>;
Loading
Loading