@tscomfy/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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/args.d.ts +24 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +66 -0
- package/dist/args.js.map +1 -0
- package/dist/check.d.ts +6 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +185 -0
- package/dist/check.js.map +1 -0
- package/dist/generate.d.ts +17 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +166 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/io.d.ts +26 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +80 -0
- package/dist/io.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -0
- package/dist/manifest.d.ts +47 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +66 -0
- package/dist/manifest.js.map +1 -0
- package/dist/report.d.ts +13 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +69 -0
- package/dist/report.js.map +1 -0
- package/dist/run.d.ts +9 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +89 -0
- package/dist/run.js.map +1 -0
- package/dist/source.d.ts +28 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +92 -0
- package/dist/source.js.map +1 -0
- package/dist/transpile.d.ts +6 -0
- package/dist/transpile.d.ts.map +1 -0
- package/dist/transpile.js +178 -0
- package/dist/transpile.js.map +1 -0
- package/dist/util.d.ts +33 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +81 -0
- package/dist/util.js.map +1 -0
- package/package.json +56 -4
- package/src/args.ts +78 -0
- package/src/check.ts +224 -0
- package/src/generate.ts +198 -0
- package/src/index.ts +12 -0
- package/src/io.ts +99 -0
- package/src/main.ts +7 -0
- package/src/manifest.ts +105 -0
- package/src/report.ts +73 -0
- package/src/run.ts +90 -0
- package/src/source.ts +114 -0
- package/src/transpile.ts +201 -0
- package/src/util.ts +87 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type Diagnostic, parse, type Schema } from "@tscomfy/core";
|
|
2
|
+
import { isRecord } from "./util.ts";
|
|
3
|
+
|
|
4
|
+
/** How many diagnostics a pack shows before the rest are summarized, unless verbose. */
|
|
5
|
+
const SHOWN_PER_PACK = 5;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Diagnostics grouped by node pack (`python_module`; docs/plan.md §9), errors and warnings
|
|
9
|
+
* counted, at most a few lines per pack unless `verbose`.
|
|
10
|
+
*/
|
|
11
|
+
export function reportDiagnostics(
|
|
12
|
+
diagnostics: readonly Diagnostic[],
|
|
13
|
+
schema: Schema,
|
|
14
|
+
objectInfo: string,
|
|
15
|
+
verbose: boolean,
|
|
16
|
+
): string {
|
|
17
|
+
if (diagnostics.length === 0) return "";
|
|
18
|
+
const packs = packsOf(schema, objectInfo);
|
|
19
|
+
const groups = new Map<string, Diagnostic[]>();
|
|
20
|
+
for (const diagnostic of diagnostics) {
|
|
21
|
+
const classType = String(diagnostic.path[0] ?? "");
|
|
22
|
+
const pack = packs.get(classType) ?? "(no pack)";
|
|
23
|
+
groups.set(pack, [...(groups.get(pack) ?? []), diagnostic]);
|
|
24
|
+
}
|
|
25
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
26
|
+
const lines = [
|
|
27
|
+
`${count(errors, "error")} and ${count(diagnostics.length - errors, "warning")} in the node definitions:`,
|
|
28
|
+
];
|
|
29
|
+
for (const pack of [...groups.keys()].toSorted()) {
|
|
30
|
+
const group = groups.get(pack) ?? [];
|
|
31
|
+
lines.push(` ${pack}: ${String(group.length)}`);
|
|
32
|
+
const shown = verbose ? group : group.slice(0, SHOWN_PER_PACK);
|
|
33
|
+
for (const diagnostic of shown) lines.push(` ${formatDiagnostic(diagnostic)}`);
|
|
34
|
+
if (shown.length < group.length) {
|
|
35
|
+
lines.push(` … and ${String(group.length - shown.length)} more (--verbose shows all)`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return `${lines.join("\n")}\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One line for a diagnostic: its code, where, and what. */
|
|
42
|
+
export function formatDiagnostic(diagnostic: Diagnostic): string {
|
|
43
|
+
return `${diagnostic.code} ${diagnostic.path.join(" › ")}: ${diagnostic.message}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `1 warning`, `3 errors`. */
|
|
47
|
+
export function count(amount: number, noun: string): string {
|
|
48
|
+
return `${String(amount)} ${noun}${amount === 1 ? "" : "s"}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A byte size for humans. */
|
|
52
|
+
export function size(bytes: number): string {
|
|
53
|
+
if (bytes < 1024) return `${String(bytes)} B`;
|
|
54
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
55
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Each class type's pack: from the schema, or from the raw response for nodes the normalizer
|
|
60
|
+
* dropped.
|
|
61
|
+
*/
|
|
62
|
+
function packsOf(schema: Schema, objectInfo: string): Map<string, string> {
|
|
63
|
+
const packs = new Map<string, string>();
|
|
64
|
+
const raw = parse(objectInfo).value;
|
|
65
|
+
if (raw !== undefined && isRecord(raw)) {
|
|
66
|
+
for (const [classType, node] of Object.entries(raw)) {
|
|
67
|
+
const module = isRecord(node) ? node["python_module"] : undefined;
|
|
68
|
+
if (typeof module === "string") packs.set(classType, module);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const node of Object.values(schema.nodes)) packs.set(node.classType, node.pythonModule);
|
|
72
|
+
return packs;
|
|
73
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { CHECK_USAGE, checkCommand } from "./check.ts";
|
|
2
|
+
import { GENERATE_USAGE, generateCommand } from "./generate.ts";
|
|
3
|
+
import { CliError, type Io } from "./io.ts";
|
|
4
|
+
import { TRANSPILE_USAGE, transpileCommand } from "./transpile.ts";
|
|
5
|
+
|
|
6
|
+
/** The CLI's version. A test keeps it equal to package.json. */
|
|
7
|
+
export const CLI_VERSION = "0.1.0";
|
|
8
|
+
|
|
9
|
+
/** `tscomfy --help`. */
|
|
10
|
+
const USAGE = `Usage: tscomfy <command> [options]
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
generate Generate typed node definitions from a ComfyUI server
|
|
14
|
+
check Check that the generated types still match the server
|
|
15
|
+
transpile Turn an API-format prompt or a UI workflow into TypeScript over the generated types
|
|
16
|
+
help Show this help, or a command's: tscomfy help <command>
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
-h, --help Show this help.
|
|
20
|
+
-v, --version Print the version.
|
|
21
|
+
|
|
22
|
+
Run tscomfy <command> --help for a command's options.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const USAGES: ReadonlyMap<string, string> = new Map([
|
|
26
|
+
["generate", GENERATE_USAGE],
|
|
27
|
+
["check", CHECK_USAGE],
|
|
28
|
+
["transpile", TRANSPILE_USAGE],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs the CLI with `args` (without the program name) and returns the exit code: 0 on success,
|
|
33
|
+
* 1 on a failure or detected drift, 2 on a usage error.
|
|
34
|
+
*/
|
|
35
|
+
export async function run(args: readonly string[], io: Io): Promise<number> {
|
|
36
|
+
const [command, ...rest] = args;
|
|
37
|
+
try {
|
|
38
|
+
switch (command) {
|
|
39
|
+
case "generate":
|
|
40
|
+
return await generateCommand(io, rest);
|
|
41
|
+
case "check":
|
|
42
|
+
return await checkCommand(io, rest);
|
|
43
|
+
case "transpile":
|
|
44
|
+
return await transpileCommand(io, rest);
|
|
45
|
+
case "-v":
|
|
46
|
+
case "--version":
|
|
47
|
+
noArguments(rest);
|
|
48
|
+
io.out(`${CLI_VERSION}\n`);
|
|
49
|
+
return 0;
|
|
50
|
+
case "-h":
|
|
51
|
+
case "--help":
|
|
52
|
+
noArguments(rest);
|
|
53
|
+
io.out(USAGE);
|
|
54
|
+
return 0;
|
|
55
|
+
case "help":
|
|
56
|
+
return help(io, rest);
|
|
57
|
+
case undefined:
|
|
58
|
+
io.err(USAGE);
|
|
59
|
+
return 2;
|
|
60
|
+
default:
|
|
61
|
+
throw new CliError(`Unknown command ${JSON.stringify(command)}.`, 2);
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!(error instanceof CliError)) throw error;
|
|
65
|
+
io.err(`tscomfy: ${error.message}\n`);
|
|
66
|
+
if (error.exitCode === 2) {
|
|
67
|
+
io.err(`\n${USAGES.get(command ?? "") ?? USAGE}`);
|
|
68
|
+
}
|
|
69
|
+
return error.exitCode;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `tscomfy help [<command>]`: the usage, or a command's. */
|
|
74
|
+
function help(io: Io, args: readonly string[]): number {
|
|
75
|
+
const [topic, ...rest] = args;
|
|
76
|
+
noArguments(rest);
|
|
77
|
+
if (topic === undefined) {
|
|
78
|
+
io.out(USAGE);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const usage = USAGES.get(topic);
|
|
82
|
+
if (usage === undefined) throw new CliError(`Unknown command ${JSON.stringify(topic)}.`, 2);
|
|
83
|
+
io.out(usage);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function noArguments(args: readonly string[]): void {
|
|
88
|
+
const [extra] = args;
|
|
89
|
+
if (extra !== undefined) throw new CliError(`Unexpected argument ${JSON.stringify(extra)}.`, 2);
|
|
90
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type Diagnostic, normalizeObjectInfo, type Schema } from "@tscomfy/core";
|
|
2
|
+
import { type Flags, value } from "./args.ts";
|
|
3
|
+
import { type Io, CliError } from "./io.ts";
|
|
4
|
+
import { errorBodyMessage, flagWords, redactUrl } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
/** Where node definitions come from: a server, or a saved `/object_info` response. */
|
|
7
|
+
export interface Source {
|
|
8
|
+
/** The `/object_info` text, exactly as served or saved. */
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly comfyVersion?: string;
|
|
11
|
+
/** For messages: the server or the file. */
|
|
12
|
+
readonly label: string;
|
|
13
|
+
/** For the regenerate command in headers: `--url <server>` or `--from <file>`. */
|
|
14
|
+
readonly flag: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reads `--url` (GET `/object_info`, and `/system_stats` for the version) or `--from`. Exactly
|
|
19
|
+
* one must be given. Only read-only GETs: tscomfy never queues anything.
|
|
20
|
+
*/
|
|
21
|
+
export async function readSource(io: Io, flags: Flags): Promise<Source> {
|
|
22
|
+
const url = value(flags, "url");
|
|
23
|
+
const from = value(flags, "from");
|
|
24
|
+
if ((url === undefined) === (from === undefined)) {
|
|
25
|
+
throw new CliError("Give either --url <server> or --from <object_info.json>.", 2);
|
|
26
|
+
}
|
|
27
|
+
if (from !== undefined) {
|
|
28
|
+
const text = await io.readText(from);
|
|
29
|
+
if (text === undefined) throw new CliError(`There is no file at ${from}.`);
|
|
30
|
+
const comfyVersion = value(flags, "comfy-version");
|
|
31
|
+
return {
|
|
32
|
+
text,
|
|
33
|
+
...(comfyVersion === undefined ? {} : { comfyVersion }),
|
|
34
|
+
label: from,
|
|
35
|
+
flag: flagWords("from", from),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const base = serverBase(url ?? "");
|
|
39
|
+
const shown = redactUrl(base).replace(/\/+$/, "");
|
|
40
|
+
const text = await io.fetchText(endpoint(base, "object_info"));
|
|
41
|
+
const comfyVersion = await readComfyVersion(io, endpoint(base, "system_stats"));
|
|
42
|
+
return {
|
|
43
|
+
text,
|
|
44
|
+
...(comfyVersion === undefined ? {} : { comfyVersion }),
|
|
45
|
+
label: shown,
|
|
46
|
+
flag: flagWords("url", shown),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The server URL. Credentials and the query (which often carries a token) are sent with each
|
|
52
|
+
* request but never shown: messages and generated headers use {@link redactUrl}.
|
|
53
|
+
*/
|
|
54
|
+
function serverBase(url: string): URL {
|
|
55
|
+
// Not echoed: an unparseable URL may still hold a password.
|
|
56
|
+
if (!URL.canParse(url)) throw new CliError("--url is not a valid URL.", 2);
|
|
57
|
+
const parsed = new URL(url);
|
|
58
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
59
|
+
throw new CliError(`${redactUrl(parsed)} is not an http(s) URL.`, 2);
|
|
60
|
+
}
|
|
61
|
+
if (parsed.hash !== "") {
|
|
62
|
+
throw new CliError("--url can't have a #fragment: it never reaches the server.", 2);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** An endpoint under the server's path, keeping its query: `http://h/api/?t=1` → `…/api/x?t=1`. */
|
|
68
|
+
function endpoint(base: URL, name: string): string {
|
|
69
|
+
const url = new URL(base.href);
|
|
70
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/${name}`;
|
|
71
|
+
return url.href;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The ComfyUI version from `/system_stats`; optional, so a failure only loses the version. */
|
|
75
|
+
async function readComfyVersion(io: Io, url: string): Promise<string | undefined> {
|
|
76
|
+
try {
|
|
77
|
+
const stats: unknown = JSON.parse(await io.fetchText(url));
|
|
78
|
+
const system: unknown =
|
|
79
|
+
typeof stats === "object" && stats !== null ? Reflect.get(stats, "system") : undefined;
|
|
80
|
+
const version: unknown =
|
|
81
|
+
typeof system === "object" && system !== null
|
|
82
|
+
? Reflect.get(system, "comfyui_version")
|
|
83
|
+
: undefined;
|
|
84
|
+
return typeof version === "string" ? version : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The normalized schema of a source. A response that holds no usable node at all (not JSON, not
|
|
92
|
+
* an object, an error body such as `{"detail": "Not authenticated"}`, or `{}`) is fatal, so it
|
|
93
|
+
* can't replace good types; anything less is reported and the rest generated.
|
|
94
|
+
*/
|
|
95
|
+
export function loadSchema(source: Source): {
|
|
96
|
+
readonly schema: Schema;
|
|
97
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
98
|
+
} {
|
|
99
|
+
const result = normalizeObjectInfo(
|
|
100
|
+
source.text,
|
|
101
|
+
source.comfyVersion === undefined ? {} : { comfyVersion: source.comfyVersion },
|
|
102
|
+
);
|
|
103
|
+
if (Object.keys(result.schema.nodes).length === 0) {
|
|
104
|
+
const detail = errorBodyMessage(source.text);
|
|
105
|
+
if (detail !== undefined) {
|
|
106
|
+
throw new CliError(`${source.label} is an error response, not node definitions: ${detail}.`);
|
|
107
|
+
}
|
|
108
|
+
const error = result.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
109
|
+
throw new CliError(
|
|
110
|
+
`${source.label} doesn't hold node definitions${error === undefined ? "" : `: ${error.message}`}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
package/src/transpile.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
CODEGEN_VERSION,
|
|
4
|
+
exportNameProblem,
|
|
5
|
+
transpile,
|
|
6
|
+
transpileNameProblem,
|
|
7
|
+
} from "@tscomfy/codegen";
|
|
8
|
+
import { hashSchema, importApi, importUi, type JsonValue, parse, typedSchema } from "@tscomfy/core";
|
|
9
|
+
import { type Flags, HELP, parseFlags, required, value } from "./args.ts";
|
|
10
|
+
import { CliError, type Io } from "./io.ts";
|
|
11
|
+
import { MANIFEST, readGenerated, SNAPSHOT } from "./manifest.ts";
|
|
12
|
+
import { count, formatDiagnostic } from "./report.ts";
|
|
13
|
+
import { camelName, flagWords, isRecord } from "./util.ts";
|
|
14
|
+
|
|
15
|
+
/** `tscomfy transpile --help`. */
|
|
16
|
+
export const TRANSPILE_USAGE: string = `Usage: tscomfy transpile (--prompt <prompt.json> | --workflow <workflow.json>) --schema <dir> [options]
|
|
17
|
+
|
|
18
|
+
Turns an API-format prompt (the frontend's "Export (API)"), or a UI workflow (its "Save" or
|
|
19
|
+
"Export"), into TypeScript that builds it with the types \`tscomfy generate\` wrote into <dir>.
|
|
20
|
+
Running the code and exporting gives the prompt back: for a UI workflow, the prompt the frontend
|
|
21
|
+
would queue for it, with Reroutes, PrimitiveNodes and subgraphs resolved. Nodes the types can't
|
|
22
|
+
express are written as raw(…), with a warning.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--prompt <file> The prompt to transpile.
|
|
26
|
+
--workflow <file> The UI workflow to transpile, instead of a prompt.
|
|
27
|
+
--schema <dir> The directory \`tscomfy generate\` wrote (it reads ${SNAPSHOT} and ${MANIFEST}).
|
|
28
|
+
--out <file> Where to write the module. Default: standard output, importing the
|
|
29
|
+
schema relative to the current directory.
|
|
30
|
+
--name <identifier> The exported constant. Default: from the file name, camelCased
|
|
31
|
+
(My Txt2Img.json → myTxt2Img).
|
|
32
|
+
--no-inline Give every node a variable, rather than inlining those used once.
|
|
33
|
+
-h, --help Show this help.
|
|
34
|
+
|
|
35
|
+
The header records the command that transpiles the file again, for a POSIX shell in the
|
|
36
|
+
directory transpile ran in. A value that starts with - is written --flag=<value>.
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const FLAGS = [
|
|
40
|
+
{ name: "prompt", takesValue: true },
|
|
41
|
+
{ name: "workflow", takesValue: true },
|
|
42
|
+
{ name: "schema", takesValue: true },
|
|
43
|
+
{ name: "out", takesValue: true },
|
|
44
|
+
{ name: "name", takesValue: true },
|
|
45
|
+
{ name: "no-inline", takesValue: false },
|
|
46
|
+
HELP,
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
/** `tscomfy transpile`. */
|
|
50
|
+
export async function transpileCommand(io: Io, args: readonly string[]): Promise<number> {
|
|
51
|
+
const flags = parseFlags(args, FLAGS);
|
|
52
|
+
if (flags.has("help")) {
|
|
53
|
+
io.out(TRANSPILE_USAGE);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (flags.has("prompt") && flags.has("workflow")) {
|
|
57
|
+
throw new CliError("Pass --prompt or --workflow, not both.", 2);
|
|
58
|
+
}
|
|
59
|
+
if (!flags.has("prompt") && !flags.has("workflow")) {
|
|
60
|
+
throw new CliError("--prompt or --workflow is required.", 2);
|
|
61
|
+
}
|
|
62
|
+
const kind = flags.has("workflow") ? "workflow" : "prompt";
|
|
63
|
+
const promptPath = required(flags, kind);
|
|
64
|
+
const schemaDir = required(flags, "schema");
|
|
65
|
+
const out = value(flags, "out");
|
|
66
|
+
const { manifest, schema } = await readGenerated(io, schemaDir);
|
|
67
|
+
const name = readName(flags, promptPath, manifest.exportName);
|
|
68
|
+
const text = await io.readText(promptPath);
|
|
69
|
+
if (text === undefined) throw new CliError(`There is no file at ${promptPath}.`);
|
|
70
|
+
|
|
71
|
+
// The types in <dir> must be the ones this version would write, or the code won't fit them.
|
|
72
|
+
if (manifest.codegen !== CODEGEN_VERSION || manifest.schema !== hashSchema(schema)) {
|
|
73
|
+
throw new CliError(
|
|
74
|
+
`${schemaDir} was generated by another version of tscomfy; run tscomfy generate again first.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const what = kind === "prompt" ? "a prompt" : "a UI workflow";
|
|
78
|
+
const mixUp = kind === "prompt" ? notAPrompt(text) : notAWorkflow(text);
|
|
79
|
+
if (mixUp !== undefined) throw new CliError(`${promptPath} isn't ${what}: ${mixUp}`);
|
|
80
|
+
const typed = typedSchema(schema);
|
|
81
|
+
const imported = kind === "prompt" ? importApi(text, typed) : importUi(text, typed);
|
|
82
|
+
const fatal = imported.diagnostics.find(
|
|
83
|
+
(diagnostic) =>
|
|
84
|
+
diagnostic.path.length === 0 &&
|
|
85
|
+
(diagnostic.code === "E_INVALID_JSON" ||
|
|
86
|
+
diagnostic.code === "E_INVALID_PROMPT" ||
|
|
87
|
+
diagnostic.code === "E_INVALID_WORKFLOW"),
|
|
88
|
+
);
|
|
89
|
+
if (fatal !== undefined) {
|
|
90
|
+
throw new CliError(`${promptPath} isn't ${what}: ${fatal.message}`);
|
|
91
|
+
}
|
|
92
|
+
if (imported.workflow.graph.nodes.size === 0) {
|
|
93
|
+
throw new CliError(`${promptPath} isn't ${what}: it has no node that can be read.`);
|
|
94
|
+
}
|
|
95
|
+
const command = [
|
|
96
|
+
"tscomfy transpile",
|
|
97
|
+
flagWords(kind, promptPath),
|
|
98
|
+
flagWords("schema", schemaDir),
|
|
99
|
+
...(out === undefined ? [] : [flagWords("out", out)]),
|
|
100
|
+
...(flags.has("name") ? [flagWords("name", name)] : []),
|
|
101
|
+
...(flags.has("no-inline") ? ["--no-inline"] : []),
|
|
102
|
+
].join(" ");
|
|
103
|
+
const result = transpile(imported.workflow, {
|
|
104
|
+
name,
|
|
105
|
+
schemaModule: moduleSpecifier(out, schemaDir, manifest.importExtension),
|
|
106
|
+
schemaName: manifest.exportName,
|
|
107
|
+
inline: !flags.has("no-inline"),
|
|
108
|
+
header: [`Regenerate with: ${command}`],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const diagnostic of [...imported.diagnostics, ...result.diagnostics]) {
|
|
112
|
+
io.err(`${formatDiagnostic(diagnostic)}\n`);
|
|
113
|
+
}
|
|
114
|
+
if (out === undefined) {
|
|
115
|
+
io.out(result.code);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
await io.writeText(out, result.code);
|
|
119
|
+
io.out(
|
|
120
|
+
`Transpiled ${count(imported.workflow.graph.nodes.size, "node")} from ${promptPath} into ${out}.\n`,
|
|
121
|
+
);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a file that isn't a prompt looks like instead, when it is a common mix-up: the frontend's
|
|
127
|
+
* UI workflow (`nodes` and `links`), or a `POST /prompt` body (`{ "prompt": … }`).
|
|
128
|
+
*/
|
|
129
|
+
function notAPrompt(text: string): string | undefined {
|
|
130
|
+
const parsed = parse(text).value;
|
|
131
|
+
if (parsed === undefined || !isRecord(parsed)) return undefined;
|
|
132
|
+
if (isUiWorkflow(parsed)) return "it is a UI workflow; pass it with --workflow instead.";
|
|
133
|
+
// A node with the id "prompt" has a class_type; a POST /prompt body's prompt doesn't.
|
|
134
|
+
const prompt = parsed["prompt"];
|
|
135
|
+
if (prompt !== undefined && isRecord(prompt) && !Object.hasOwn(prompt, "class_type")) {
|
|
136
|
+
return "it is a POST /prompt body; save its prompt on its own.";
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What a file passed as a UI workflow looks like instead: a prompt, or a `POST /prompt` body. */
|
|
142
|
+
function notAWorkflow(text: string): string | undefined {
|
|
143
|
+
const parsed = parse(text).value;
|
|
144
|
+
if (parsed === undefined || !isRecord(parsed) || isUiWorkflow(parsed)) return undefined;
|
|
145
|
+
const nodes = Object.values(parsed);
|
|
146
|
+
if (nodes.length > 0 && nodes.every((node) => isRecord(node) && "class_type" in node)) {
|
|
147
|
+
return "it is an API prompt; pass it with --prompt instead.";
|
|
148
|
+
}
|
|
149
|
+
if (Object.hasOwn(parsed, "prompt")) {
|
|
150
|
+
return "it is a POST /prompt body; save its prompt on its own and pass it with --prompt.";
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isUiWorkflow(parsed: { readonly [key: string]: JsonValue }): boolean {
|
|
156
|
+
return Array.isArray(parsed["nodes"]) && Array.isArray(parsed["links"]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* How the module imports the schema: relative to where it is written (the current directory
|
|
161
|
+
* without --out), with POSIX separators.
|
|
162
|
+
*/
|
|
163
|
+
function moduleSpecifier(out: string | undefined, schemaDir: string, extension: string): string {
|
|
164
|
+
const from = out === undefined ? "." : dirname(out);
|
|
165
|
+
const target = relative(from, join(schemaDir, `index${extension}`));
|
|
166
|
+
// On another drive (Windows) there is no relative path.
|
|
167
|
+
if (isAbsolute(target)) {
|
|
168
|
+
throw new CliError(
|
|
169
|
+
"--schema and --out are on different drives; no relative import joins them.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const posix = target.split(sep).join("/");
|
|
173
|
+
return posix.startsWith("./") || posix.startsWith("../") ? posix : `./${posix}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The export name: `--name`, or from the file name (`Anima-API.json` → `animaApi`, see
|
|
178
|
+
* `camelName`). The schema's name and this one must both be usable by `transpile`, by the rule
|
|
179
|
+
* `generate` checked the schema's with.
|
|
180
|
+
*/
|
|
181
|
+
function readName(flags: Flags, promptPath: string, schemaName: string): string {
|
|
182
|
+
const schemaProblem = exportNameProblem(schemaName);
|
|
183
|
+
if (schemaProblem !== undefined) {
|
|
184
|
+
// Generated before generate checked this.
|
|
185
|
+
throw new CliError(
|
|
186
|
+
`The schema's export name, ${schemaName}, won't do: ${schemaProblem}. Run tscomfy generate again with another --name.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const given = value(flags, "name");
|
|
190
|
+
const name = given ?? camelName(basename(promptPath).replace(/\.[^.]*$/, ""));
|
|
191
|
+
const problem = exportNameProblem(name) ?? transpileNameProblem({ name, schemaName });
|
|
192
|
+
if (problem !== undefined) {
|
|
193
|
+
throw new CliError(
|
|
194
|
+
given === undefined
|
|
195
|
+
? `Can't name the export ${JSON.stringify(name)}, after ${JSON.stringify(promptPath)}: ${problem}; pass --name.`
|
|
196
|
+
: `--name ${JSON.stringify(given)} isn't usable: ${problem}.`,
|
|
197
|
+
2,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return name;
|
|
201
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type JsonValue, parse, stringify } from "@tscomfy/core";
|
|
2
|
+
|
|
3
|
+
/** Whether a JSON value is an object (not null, not an array). */
|
|
4
|
+
export function isRecord(value: JsonValue): value is { readonly [key: string]: JsonValue } {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** A word for a POSIX shell, quoted only when it has to be: `out dir` → `'out dir'`. */
|
|
9
|
+
export function shellQuote(word: string): string {
|
|
10
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `--name value` for a POSIX shell, as the CLI reads it back: `--name=value` when the value
|
|
15
|
+
* starts with `-`, which the separate form would take for a flag (`--out=--weird`).
|
|
16
|
+
*/
|
|
17
|
+
export function flagWords(name: string, value: string): string {
|
|
18
|
+
return value.startsWith("-")
|
|
19
|
+
? `--${name}=${shellQuote(value)}`
|
|
20
|
+
: `--${name} ${shellQuote(value)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An export name from a file or directory name, the same rule for `generate --out` and
|
|
25
|
+
* `transpile --prompt`: camelCase from the runs of ASCII letters and digits, a word in capitals
|
|
26
|
+
* lowercased, the first word starting lowercase and the others uppercase
|
|
27
|
+
* (`src/comfy/local` → `local`, `my-server` → `myServer`, `Anima-API.json` → `animaApi`).
|
|
28
|
+
* May be empty or start with a digit: callers check the result.
|
|
29
|
+
*/
|
|
30
|
+
export function camelName(text: string): string {
|
|
31
|
+
const words = text.match(/[A-Za-z0-9]+/g) ?? [];
|
|
32
|
+
return words
|
|
33
|
+
.map((word, index) => {
|
|
34
|
+
const lower = word === word.toUpperCase() ? word.toLowerCase() : word;
|
|
35
|
+
return index === 0
|
|
36
|
+
? lower.charAt(0).toLowerCase() + lower.slice(1)
|
|
37
|
+
: lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
38
|
+
})
|
|
39
|
+
.join("");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How much of an error body's message a message shows. */
|
|
43
|
+
const SHOWN_DETAIL = 200;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The message of an error body, when `text` is one rather than an answer: an object with a lone
|
|
47
|
+
* `detail`, `error` or `message` (`{"detail": "Not authenticated"}`, as auth proxies in front of
|
|
48
|
+
* ComfyUI send), whose value isn't a node definition. Shown as compact JSON, at most 200
|
|
49
|
+
* characters, with every control character escaped, so none reaches the terminal.
|
|
50
|
+
*/
|
|
51
|
+
export function errorBodyMessage(text: string): string | undefined {
|
|
52
|
+
const value = parse(text).value;
|
|
53
|
+
if (value === undefined || !isRecord(value)) return undefined;
|
|
54
|
+
const keys = Object.keys(value);
|
|
55
|
+
const [key] = keys;
|
|
56
|
+
if (keys.length !== 1 || (key !== "detail" && key !== "error" && key !== "message")) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const message = value[key];
|
|
60
|
+
if (message === undefined || message === null) return undefined;
|
|
61
|
+
// A node class named `error` is an object with its inputs and outputs.
|
|
62
|
+
if (isRecord(message) && (Object.hasOwn(message, "input") || Object.hasOwn(message, "output"))) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
// `stringify` escapes C0 controls; DEL and C1 controls (a CSI is U+009B) are escaped here.
|
|
66
|
+
const shown = stringify(message).replaceAll(
|
|
67
|
+
/[\u007f-\u009f]/g,
|
|
68
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
69
|
+
);
|
|
70
|
+
if (shown.length <= SHOWN_DETAIL) return shown;
|
|
71
|
+
const cut = shown.slice(0, SHOWN_DETAIL);
|
|
72
|
+
// Not half a surrogate pair.
|
|
73
|
+
return `${/[\ud800-\udbff]$/.test(cut) ? cut.slice(0, -1) : cut}…`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A URL fit for messages and generated files: without credentials, query or fragment, which is
|
|
78
|
+
* where servers expect passwords and tokens. `http://user:pw@host/api?token=t` → `http://host/api`.
|
|
79
|
+
*/
|
|
80
|
+
export function redactUrl(url: URL): string {
|
|
81
|
+
const shown = new URL(url.href);
|
|
82
|
+
shown.username = "";
|
|
83
|
+
shown.password = "";
|
|
84
|
+
shown.search = "";
|
|
85
|
+
shown.hash = "";
|
|
86
|
+
return shown.href;
|
|
87
|
+
}
|