Skip to content

Commit 1953266

Browse files
committed
✨Add run command and documentation
As part of the documentation for the install and getting started, we need to be able to print the version of platform script, and also be able to run some platform script code. This was suprisingly difficult, so we want to be able to put in place a system to make adding commands, specifying their options, and automatically generating help for them simple. This uses the "router" strategy where commands are a hierarchy of word matches, and then the options are like query params for those matches, so the main dispatch can look like this: ```ts dispatch(["pls", ...args], { "pls": [PlsCommand, { "run :MODULE": RunCommand, "test FILES...": TestCommand, }] }); ``` This lets the parent command `PlsCommand` perform any asynchrony it wants before finally yielding to its "children" which will allow us to do any global setup, which again, can be async. It also allows us to add advanced concurrent global options like a `--watch` parameter that spawns and restarts its children instead of just running them. At the root is the `PlsCommand` which defines the `--version` options and `--help` options. There is a single command below the root (for now) which is the `RunCommand` The run command accepts a MODULE from either the local file system or the network, evaluates that module, and then prints out its value. The docs have been updated to contain the installation process, and a way to make sure that your installation is working.
1 parent 5891569 commit 1953266

13 files changed

Lines changed: 362 additions & 30 deletions

File tree

cli/deferred.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface Deferred<T = void> {
2+
promise: Promise<T>;
3+
resolve: (value: T) => void;
4+
reject: (error: Error) => void;
5+
}
6+
7+
export function defer<T>(): Deferred<T> {
8+
let resolve: Deferred<T>["resolve"] | undefined = void 0;
9+
let reject: Deferred<T>["reject"] | undefined = void 0;
10+
let promise = new Promise<T>((res, rej) => {
11+
resolve = res;
12+
reject = rej;
13+
});
14+
if (!resolve || !reject) {
15+
throw new Error("unable to create Deferred!");
16+
} else {
17+
return { resolve, reject, promise };
18+
}
19+
}

cli/main.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Operation, Task } from "../deps.ts";
2+
import { action, run, spawn, suspend } from "../deps.ts";
3+
import { defer } from "./deferred.ts";
4+
5+
export function main(op: (args: string[]) => Operation<void>): Task<void> {
6+
return run(function* Main() {
7+
let done = defer<number>();
8+
9+
yield* spawn(function* () {
10+
try {
11+
yield* op(Deno.args);
12+
done.resolve(0);
13+
} catch (error) {
14+
console.error(String(error));
15+
done.resolve(1);
16+
}
17+
});
18+
19+
let code = yield* action<number>(function* (resolve) {
20+
done.promise.then(resolve);
21+
let interrupt = () => resolve(1);
22+
try {
23+
Deno.addSignalListener("SIGINT", interrupt);
24+
yield* suspend();
25+
} finally {
26+
Deno.removeSignalListener("SIGINT", interrupt);
27+
}
28+
});
29+
30+
Deno.exit(code);
31+
});
32+
}

cli/pls-command.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Route } from "./router.ts";
2+
3+
import VERSION from "../version.json" assert { type: "json" };
4+
5+
export const PlsCommand: Route = {
6+
options: [
7+
{
8+
type: "boolean",
9+
name: "version",
10+
alias: "V",
11+
description: "Print version information",
12+
},
13+
],
14+
help: {
15+
HEAD: `pls ${VERSION}`,
16+
USAGE: "pls [OPTIONS] COMMAND",
17+
},
18+
*handle({ flags, children }) {
19+
if (flags.version) {
20+
console.log(VERSION);
21+
} else {
22+
yield* children;
23+
}
24+
},
25+
};

cli/pls.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { main } from "./main.ts";
2+
import { dispatch } from "./router.ts";
3+
import { PlsCommand } from "./pls-command.ts";
4+
import { RunCommand } from "./run-command.ts";
5+
6+
await main(function* (args) {
7+
yield* dispatch(["pls", ...args], {
8+
"pls": [PlsCommand, {
9+
"run :MODULE": RunCommand,
10+
}],
11+
});
12+
});

cli/router.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { Operation } from "../deps.ts";
2+
3+
import {
4+
parse as parseFlags,
5+
ParseOptions,
6+
} from "https://deno.land/std@0.159.0/flags/mod.ts";
7+
8+
export function* dispatch(
9+
args: string[],
10+
routes: Routes,
11+
matched: string[] = [],
12+
): Operation<void> {
13+
let shellwords = parseFlags(args)._;
14+
match:
15+
for (let [key, value] of Object.entries(routes)) {
16+
let pattern: PatternElement[] = key.split(/\s/).map((name) => {
17+
if (name.startsWith(":")) {
18+
return { type: "dynamic", required: true, name: name.slice(1) };
19+
} else {
20+
return { type: "static", value: name };
21+
}
22+
});
23+
let segments: Record<string, string> = {};
24+
let rest = args.slice();
25+
let copy = shellwords.slice();
26+
for (let element of pattern) {
27+
let top = copy.shift();
28+
29+
if (element.type === "dynamic") {
30+
if (top) {
31+
segments[element.name] = String(top);
32+
rest.splice(rest.indexOf(String(top), 1));
33+
matched = matched.concat(element.name);
34+
}
35+
} else if (element.value !== top) {
36+
continue match;
37+
} else {
38+
rest.splice(rest.indexOf(element.value), 1);
39+
matched = matched.concat(element.value);
40+
}
41+
}
42+
let [handler, subroutes] = Array.isArray(value) ? value : [value];
43+
44+
let helpful = helpify(handler);
45+
46+
let parseOptions = routeOptions(helpful);
47+
48+
let flags = parseFlags(rest, parseOptions);
49+
50+
let children = subroutes
51+
? dispatch(flags._.map(String), subroutes, matched)
52+
: { *[Symbol.iterator]() {} };
53+
54+
let props: RouteProps = {
55+
flags,
56+
route: helpful,
57+
children,
58+
segments,
59+
};
60+
61+
return yield* helpful.handle(props);
62+
}
63+
}
64+
65+
function routeOptions(route: Route): ParseOptions {
66+
return route.options.reduce((options, spec) => {
67+
options[spec.type].push(spec.name);
68+
if (spec.alias) {
69+
options.alias[spec.name] = spec.alias;
70+
}
71+
return options;
72+
}, {
73+
boolean: [] as string[],
74+
number: [] as string[],
75+
string: [] as string[],
76+
alias: {} as Record<string, string>,
77+
stopEarly: true,
78+
});
79+
}
80+
81+
function helpify(route: Route): Route {
82+
let options = route.options.slice();
83+
options.splice(0, 0, {
84+
type: "boolean",
85+
name: "help",
86+
alias: "h",
87+
description: "Print help information",
88+
});
89+
return {
90+
...route,
91+
options,
92+
*handle(props) {
93+
if (props.flags.help) {
94+
printHelp(props.route);
95+
} else {
96+
yield* route.handle(props);
97+
}
98+
},
99+
};
100+
}
101+
102+
export type PatternElement = {
103+
type: "dynamic";
104+
name: string;
105+
required: boolean;
106+
} | {
107+
type: "static";
108+
value: string;
109+
};
110+
111+
export interface RouteProps<TFlags = unknown> {
112+
flags: TFlags;
113+
route: Route;
114+
segments: Record<string, string>;
115+
children: Operation<void>;
116+
}
117+
118+
export interface Route {
119+
options: {
120+
type: "boolean" | "string" | "number";
121+
name: string;
122+
alias?: string;
123+
description: string;
124+
}[];
125+
help: {
126+
HEAD: string;
127+
USAGE: string;
128+
};
129+
handle(props: RouteProps): Operation<void>;
130+
}
131+
132+
export type Routes = Record<string, Route | [Route, Routes]>;
133+
134+
function printHelp(route: Route): void {
135+
let optionsTable = route.options.map((opt) => {
136+
let line = new Array(80).fill(" ", 0, 79);
137+
if (opt.alias) {
138+
line.splice(4, 3, ...`-${opt.alias},`);
139+
}
140+
line.splice(8, opt.name.length + 2, ...`--${opt.name}`);
141+
line.splice(20, opt.description.length, ...opt.description);
142+
return line.join("");
143+
}).join("\n");
144+
console.log(`${route.help.HEAD}\n`);
145+
console.log(`USAGE:\n ${route.help.USAGE}\n`);
146+
console.log(`OPTIONS:\n${optionsTable}`);
147+
}

cli/run-command.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Route } from "./router.ts";
2+
import { load, print } from "../mod.ts";
3+
import { LogContext, resolve, spawn } from "../deps.ts";
4+
5+
export const RunCommand: Route = {
6+
options: [],
7+
help: {
8+
HEAD: "Evaluate a PlatformScript program",
9+
USAGE: "pls run [OPTIONS] MODULE",
10+
},
11+
*handle({ segments, route }) {
12+
if (!segments.MODULE) {
13+
console.error(`USAGE: ${route.help.USAGE}
14+
missing required argument MODULE`);
15+
return;
16+
}
17+
18+
let address = segments.MODULE;
19+
20+
let location = isURL(address) ? address : `file://${resolve(address)}`;
21+
// setup logging context to output to stdout.
22+
yield* spawn(function* () {
23+
let logs = yield* LogContext;
24+
let i = yield* logs.output;
25+
for (let next = yield* i; !next.done; next = yield* i) {
26+
console.log(next.value.message);
27+
}
28+
});
29+
// load the module
30+
let mod = yield* load({ location });
31+
32+
// print its value
33+
let str = print(mod.value);
34+
console.log(str.value);
35+
},
36+
};
37+
38+
function isURL(value: string): boolean {
39+
try {
40+
new URL(value);
41+
return true;
42+
} catch (_) {
43+
return false;
44+
}
45+
}

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"tasks": {
3-
"test": "deno test --allow-read --allow-net",
3+
"pls": "deno run -A cli/pls.ts",
4+
"test": "deno test --allow-read --allow-net --allow-run",
45
"build:npm": "deno run -A tasks/build-npm.ts",
56
"changelog-entry": "deno run -A tasks/changelog-entry.ts",
67

pls.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

test/cli.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from "./suite.ts";
2+
import VERSION from "../version.json" assert { type: "json" };
3+
4+
describe("pls", () => {
5+
it("can be invoked for help", async () => {
6+
expect(await exec("-h")).toContain(VERSION);
7+
expect(await exec("--help")).toContain(VERSION);
8+
});
9+
10+
it("can be invoked for version", async () => {
11+
expect(await exec("-V")).toEqual(`${VERSION}\n`);
12+
expect(await exec("--version")).toEqual(`${VERSION}\n`);
13+
});
14+
15+
describe("run", () => {
16+
it("evaluates a module and prints it", async () => {
17+
expect(await exec("run test/modules/1.yaml")).toEqual("1\n");
18+
});
19+
});
20+
});
21+
22+
async function exec(str: string) {
23+
let p = Deno.run({
24+
stdout: "piped",
25+
stderr: "piped",
26+
cmd: ["deno", "run", "-A", "cli/pls.ts", ...str.split(/\s+/)],
27+
});
28+
try {
29+
let status = await p.status();
30+
let output = new TextDecoder().decode(await p.output());
31+
if (!status.success) {
32+
throw new Error(`${status}: ${output}`);
33+
} else {
34+
return output;
35+
}
36+
} finally {
37+
p.close();
38+
p.stderr.close();
39+
}
40+
}

www/components/main-page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function createMainPage<TProps extends MainPageProps = MainPageProps>(
2323
</Head>
2424
<div class="h-screen flex flex-col">
2525
<Header className="flex justify-between" active={props.data.active} />
26-
<main style={{flexGrow: 1}}>
26+
<main style={{ flexGrow: 1 }}>
2727
<Component {...props} />
2828
</main>
2929
<Footer className="border(t-2 gray-200) bg-gray-100 h-32 flex flex-col gap-4 justify-center" />

0 commit comments

Comments
 (0)