Skip to content

Commit 283f437

Browse files
committed
feat: implement CLI export command for Action as JSON Module
1 parent 5f93f44 commit 283f437

7 files changed

Lines changed: 233 additions & 5 deletions

File tree

ts/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,22 @@ cd examples/simple-examples-ts
77
npm install
88
npm run dev
99
```
10+
11+
## Exporting an action as a Module JSON
12+
13+
Every project with `@code0-tech/hercules` installed gets a `hercules` CLI. The
14+
`export` command loads your entry file **without connecting to aquila** and
15+
prints the complete action as a JSON document in the shape of the tucana
16+
protobuf `shared.Module` type:
17+
18+
```bash
19+
npx hercules export src/index.ts # print to stdout
20+
npx hercules export src/index.ts -o module.json
21+
npx hercules export dist/index.js --compact # built JS entry, compact JSON
22+
```
23+
24+
The command finds your `Action` automatically: either export the instance from
25+
the entry file, or simply construct it during module load (calls to
26+
`action.connect(...)` are turned into no-ops while exporting). Loading a
27+
TypeScript entry directly requires `tsx` as a dev dependency; alternatively
28+
point the command at your built JavaScript entry.

ts/bin/hercules.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
import "../dist/cli.es.js";

ts/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
"require": "./dist/hercules.cjs.js"
1717
}
1818
},
19+
"bin": {
20+
"hercules": "./bin/hercules.js"
21+
},
1922
"files": [
20-
"dist"
23+
"dist",
24+
"bin"
2125
],
2226
"scripts": {
2327
"build": "npm run build:definitions && vite build",

ts/src/action.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ import {EventManager} from "./manager/EventManager";
2525
import {RuntimeEventManager} from "./manager/RuntimeEventManager";
2626
import {actions} from "./actions";
2727

28+
// Global registry keyed via Symbol.for so the CLI finds actions even when the
29+
// user's code loaded a different copy of this module (e.g. the CJS build).
30+
const ACTION_REGISTRY = Symbol.for("hercules.actions");
31+
32+
export function isExportMode(): boolean {
33+
return process.env.HERCULES_EXPORT === "1";
34+
}
35+
36+
export function registeredActions(): readonly Action[] {
37+
return (globalThis as Record<symbol, unknown>)[ACTION_REGISTRY] as Action[] ?? [];
38+
}
39+
2840
export class Action extends EventEmitter<CodeZeroEventMap> {
2941
private _transport?: GrpcTransport;
3042
private _stream?: DuplexStreamingCall<ActionTransferRequest, ActionTransferResponse>;
@@ -48,6 +60,11 @@ export class Action extends EventEmitter<CodeZeroEventMap> {
4860
private readonly _configurationDefinitions: ConfigurationDefinition[] = [],
4961
) {
5062
super();
63+
if (isExportMode()) {
64+
const registry = (globalThis as Record<symbol, unknown>)[ACTION_REGISTRY] as Action[] | undefined;
65+
if (registry) registry.push(this);
66+
else (globalThis as Record<symbol, unknown>)[ACTION_REGISTRY] = [this];
67+
}
5168
}
5269

5370
get identifier() { return this._identifier; }
@@ -114,11 +131,12 @@ export class Action extends EventEmitter<CodeZeroEventMap> {
114131
}
115132

116133
async connect(authToken: string, aquilaUrl?: string, grpcOptions?: GrpcOptions) {
134+
if (isExportMode()) return;
117135
const url = aquilaUrl ?? this._aquilaUrl;
118136
if (!url) throw new Error("aquilaUrl must be provided in the constructor or connect()");
119137

120138
try {
121-
const {transport, stream} = await createConnection(this._buildModule(), authToken, url, grpcOptions);
139+
const {transport, stream} = await createConnection(this.buildModule(), authToken, url, grpcOptions);
122140
this._transport = transport;
123141
this._stream = stream;
124142
} catch (err) {
@@ -142,7 +160,7 @@ export class Action extends EventEmitter<CodeZeroEventMap> {
142160
}
143161
}
144162

145-
private _buildModule() {
163+
buildModule() {
146164
return buildModule({
147165
identifier: this._identifier, version: this._version,
148166
author: this._author, icon: this._icon, documentation: this._documentation,

ts/src/cli.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import "reflect-metadata";
2+
import {existsSync, writeFileSync} from "node:fs";
3+
import {createRequire} from "node:module";
4+
import {resolve} from "node:path";
5+
import {pathToFileURL} from "node:url";
6+
import {Module} from "@code0-tech/tucana/shared";
7+
import {Action, registeredActions} from "./action";
8+
9+
const USAGE = `Usage: hercules export <entry-file> [options]
10+
11+
Loads the given entry file (without connecting to aquila) and prints the
12+
Action as a JSON document in the shape of the tucana protobuf Module type.
13+
14+
Options:
15+
-o, --out <file> Write the JSON to a file instead of stdout
16+
--compact Emit compact JSON instead of pretty-printed
17+
-h, --help Show this help message
18+
19+
Examples:
20+
hercules export src/index.ts
21+
hercules export dist/index.js -o module.json
22+
`;
23+
24+
interface ExportArgs {
25+
entry: string;
26+
out?: string;
27+
compact: boolean;
28+
}
29+
30+
function fail(message: string): never {
31+
process.stderr.write(`${message}\n`);
32+
process.exit(1);
33+
}
34+
35+
function parseExportArgs(argv: string[]): ExportArgs {
36+
let entry: string | undefined;
37+
let out: string | undefined;
38+
let compact = false;
39+
for (let i = 0; i < argv.length; i++) {
40+
const arg = argv[i];
41+
if (arg === "-h" || arg === "--help") {
42+
process.stdout.write(USAGE);
43+
process.exit(0);
44+
} else if (arg === "-o" || arg === "--out") {
45+
out = argv[++i];
46+
if (out == null) fail(`Missing value for ${arg}\n\n${USAGE}`);
47+
} else if (arg === "--compact") {
48+
compact = true;
49+
} else if (arg.startsWith("-")) {
50+
fail(`Unknown option: ${arg}\n\n${USAGE}`);
51+
} else if (entry == null) {
52+
entry = arg;
53+
} else {
54+
fail(`Unexpected argument: ${arg}\n\n${USAGE}`);
55+
}
56+
}
57+
if (entry == null) fail(`Missing <entry-file> argument\n\n${USAGE}`);
58+
return {entry, out, compact};
59+
}
60+
61+
function isAction(value: unknown): value is Action {
62+
if (value instanceof Action) return true;
63+
// The entry file may have loaded a different copy of this module (e.g. the
64+
// CJS build), in which case instanceof fails — fall back to duck typing.
65+
const candidate = value as Action | null;
66+
return typeof candidate?.buildModule === "function"
67+
&& typeof candidate.identifier === "string"
68+
&& typeof candidate.registerFunction === "function";
69+
}
70+
71+
function findAction(entryExports: Record<string, unknown>): Action {
72+
for (const value of [entryExports.default, ...Object.values(entryExports)]) {
73+
if (isAction(value)) return value;
74+
}
75+
const registered = registeredActions();
76+
if (registered.length > 0) return registered[registered.length - 1];
77+
throw new Error("No Action found. Export your Action instance from the entry file or construct it during module load.");
78+
}
79+
80+
async function loadEntry(entry: string): Promise<Record<string, unknown>> {
81+
const entryPath = resolve(process.cwd(), entry);
82+
if (!existsSync(entryPath)) fail(`Entry file not found: ${entryPath}`);
83+
if (/\.[mc]?tsx?$/.test(entryPath)) {
84+
const tsxApi = "tsx/esm/api";
85+
let tsx;
86+
try {
87+
tsx = await import(/* @vite-ignore */ tsxApi);
88+
} catch {
89+
try {
90+
// Not resolvable from the hercules install; try the user's project.
91+
const requireFromCwd = createRequire(resolve(process.cwd(), "package.json"));
92+
tsx = await import(/* @vite-ignore */ pathToFileURL(requireFromCwd.resolve(tsxApi)).href);
93+
} catch {
94+
fail("Loading a TypeScript entry requires 'tsx'. Install it (npm i -D tsx) or point the command at your built JavaScript entry.");
95+
}
96+
}
97+
tsx.register();
98+
}
99+
return await import(/* @vite-ignore */ pathToFileURL(entryPath).href);
100+
}
101+
102+
async function runExport(argv: string[]) {
103+
const args = parseExportArgs(argv);
104+
// Must be set before the entry file is loaded: it makes connect() a no-op
105+
// and registers constructed Action instances for findAction.
106+
process.env.HERCULES_EXPORT = "1";
107+
const action = findAction(await loadEntry(args.entry));
108+
const moduleJson = Module.toJson(action.buildModule());
109+
const json = `${JSON.stringify(moduleJson, null, args.compact ? undefined : 2)}\n`;
110+
if (args.out) {
111+
const outPath = resolve(process.cwd(), args.out);
112+
writeFileSync(outPath, json);
113+
process.stderr.write(`Module written to ${outPath}\n`);
114+
} else {
115+
process.stdout.write(json);
116+
}
117+
}
118+
119+
async function main() {
120+
const [command, ...rest] = process.argv.slice(2);
121+
if (command == null || command === "-h" || command === "--help") {
122+
process.stdout.write(USAGE);
123+
process.exit(command == null ? 1 : 0);
124+
}
125+
if (command !== "export") fail(`Unknown command: ${command}\n\n${USAGE}`);
126+
await runExport(rest);
127+
// The entry file may have started timers or other handles; exit explicitly.
128+
process.exit(0);
129+
}
130+
131+
main().catch((err: unknown) => fail(err instanceof Error ? err.message : String(err)));

ts/test/export.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import "reflect-metadata";
2+
import { Module } from "@code0-tech/tucana/shared";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import { Action, registeredActions } from "../src/action";
5+
6+
function createAction(identifier = "test-action") {
7+
return new Action(
8+
identifier,
9+
"1.2.3",
10+
undefined,
11+
"code0-tech",
12+
"tabler:bolt",
13+
"docs",
14+
[{ code: "en-US", content: "Test Action" }],
15+
);
16+
}
17+
18+
describe("Action export mode", () => {
19+
afterEach(() => {
20+
delete process.env.HERCULES_EXPORT;
21+
});
22+
23+
it("buildModule produces a Module serializable as protobuf JSON", () => {
24+
const action = createAction();
25+
const json = Module.toJson(action.buildModule());
26+
const roundtrip = Module.fromJson(json);
27+
expect(roundtrip.identifier).toBe("test-action");
28+
expect(roundtrip.version).toBe("1.2.3");
29+
expect(roundtrip.author).toBe("code0-tech");
30+
});
31+
32+
it("registers constructed actions globally when HERCULES_EXPORT is set", () => {
33+
process.env.HERCULES_EXPORT = "1";
34+
const action = createAction("registered-action");
35+
const registered = registeredActions();
36+
expect(registered[registered.length - 1]).toBe(action);
37+
});
38+
39+
it("does not register actions without HERCULES_EXPORT", () => {
40+
const before = registeredActions().length;
41+
createAction("unregistered-action");
42+
expect(registeredActions().length).toBe(before);
43+
});
44+
45+
it("connect is a no-op in export mode even without an aquila url", async () => {
46+
process.env.HERCULES_EXPORT = "1";
47+
const action = createAction();
48+
await expect(action.connect("token")).resolves.toBeUndefined();
49+
expect(action.stream).toBeUndefined();
50+
});
51+
});

ts/vite.config.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ export default defineConfig({
77
build: {
88
target: 'node18',
99
lib: {
10-
entry: resolve(__dirname, 'src/index.ts'),
10+
entry: {
11+
hercules: resolve(__dirname, 'src/index.ts'),
12+
cli: resolve(__dirname, 'src/cli.ts'),
13+
},
1114
name: 'triangulum',
12-
fileName: (format) => `hercules.${format}.js`,
15+
fileName: (format, entryName) => `${entryName}.${format}.js`,
1316
formats: ['es', 'cjs']
1417
},
1518
rollupOptions: {

0 commit comments

Comments
 (0)