@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/check.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { CODEGEN_VERSION } from "@tscomfy/codegen";
|
|
3
|
+
import {
|
|
4
|
+
diffSchemas,
|
|
5
|
+
hashSchema,
|
|
6
|
+
type InputChange,
|
|
7
|
+
type NodeChange,
|
|
8
|
+
type Schema,
|
|
9
|
+
type SchemaDiff,
|
|
10
|
+
} from "@tscomfy/core";
|
|
11
|
+
import { HELP, parseFlags, required } from "./args.ts";
|
|
12
|
+
import { regenerateCommand } from "./generate.ts";
|
|
13
|
+
import type { Io } from "./io.ts";
|
|
14
|
+
import { contentHash, FILES, MANIFEST, readGenerated, SNAPSHOT } from "./manifest.ts";
|
|
15
|
+
import { count } from "./report.ts";
|
|
16
|
+
import { loadSchema, readSource } from "./source.ts";
|
|
17
|
+
|
|
18
|
+
/** `tscomfy check --help`. */
|
|
19
|
+
export const CHECK_USAGE: string = `Usage: tscomfy check (--url <server> | --from <object_info.json>) --out <dir>
|
|
20
|
+
|
|
21
|
+
Compares the server's node definitions with the snapshot in <dir>/${SNAPSHOT}, and checks
|
|
22
|
+
that the files in <dir> are exactly what \`tscomfy generate\` wrote (<dir>/${MANIFEST}). Exits
|
|
23
|
+
with 1 when anything differs, so it can guard a build. New files in upload and remote combos
|
|
24
|
+
(an uploaded image) are runtime state, not drift.
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--url <server> A ComfyUI server, e.g. http://127.0.0.1:8188. Only GET requests.
|
|
28
|
+
--from <file> A saved /object_info response instead of a server.
|
|
29
|
+
--out <dir> The directory \`tscomfy generate\` wrote.
|
|
30
|
+
--verbose List every changed node, input and combo option, rather than the first few.
|
|
31
|
+
-h, --help Show this help.
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const FLAGS = [
|
|
35
|
+
{ name: "url", takesValue: true },
|
|
36
|
+
{ name: "from", takesValue: true },
|
|
37
|
+
{ name: "out", takesValue: true },
|
|
38
|
+
{ name: "verbose", takesValue: false },
|
|
39
|
+
HELP,
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
/** How many node and input names a list shows before the rest are counted, unless verbose. */
|
|
43
|
+
const SHOWN_NAMES = 10;
|
|
44
|
+
|
|
45
|
+
/** How many combo options a change lists before the rest are counted, unless verbose. */
|
|
46
|
+
const SHOWN_OPTIONS = 5;
|
|
47
|
+
|
|
48
|
+
/** What follows a count of entries left out. */
|
|
49
|
+
const MORE = " (--verbose shows all)";
|
|
50
|
+
|
|
51
|
+
/** `tscomfy check`. */
|
|
52
|
+
export async function checkCommand(io: Io, args: readonly string[]): Promise<number> {
|
|
53
|
+
const flags = parseFlags(args, FLAGS);
|
|
54
|
+
if (flags.has("help")) {
|
|
55
|
+
io.out(CHECK_USAGE);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const out = required(flags, "out");
|
|
59
|
+
const verbose = flags.has("verbose");
|
|
60
|
+
const { manifest, snapshotPath, snapshotText, schema: snapshot } = await readGenerated(io, out);
|
|
61
|
+
const source = await readSource(io, flags);
|
|
62
|
+
const current = loadSchema(source).schema;
|
|
63
|
+
|
|
64
|
+
// Files that don't hash as recorded were edited, removed or left half-written.
|
|
65
|
+
const stale: string[] = [];
|
|
66
|
+
for (const file of FILES) {
|
|
67
|
+
const hash = manifest.files[file];
|
|
68
|
+
// Sequential: a few small reads, and a stable order for the message.
|
|
69
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
70
|
+
const text = file === SNAPSHOT ? snapshotText : await io.readText(join(out, file));
|
|
71
|
+
if (text === undefined || contentHash(text) !== hash) stale.push(file);
|
|
72
|
+
}
|
|
73
|
+
// Another codegen, or another core normalizing the same snapshot differently.
|
|
74
|
+
const outdated = manifest.codegen !== CODEGEN_VERSION || manifest.schema !== hashSchema(snapshot);
|
|
75
|
+
const { diff, runtime } = withoutRuntimeOptions(diffSchemas(snapshot, current), current);
|
|
76
|
+
const drifted = diff.added.length + diff.removed.length + diff.changed.length > 0;
|
|
77
|
+
|
|
78
|
+
const ignored =
|
|
79
|
+
runtime.length === 0
|
|
80
|
+
? ""
|
|
81
|
+
: ` (changed files in ${count(runtime.length, "upload or remote combo")} aren't drift: ${listed(runtime, verbose, SHOWN_NAMES)})`;
|
|
82
|
+
if (!drifted && stale.length === 0 && !outdated) {
|
|
83
|
+
const nodes = Object.keys(current.nodes).length;
|
|
84
|
+
io.out(
|
|
85
|
+
`Up to date: ${count(nodes, "node type")} ${nodes === 1 ? "matches" : "match"} ${snapshotPath}${ignored}.\n`,
|
|
86
|
+
);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
if (stale.length > 0) {
|
|
91
|
+
lines.push(
|
|
92
|
+
`${list(stale)} in ${out} ${stale.length === 1 ? "isn't" : "aren't"} what tscomfy generate wrote: edited, missing, or from an interrupted run.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (outdated) {
|
|
96
|
+
lines.push(
|
|
97
|
+
manifest.codegen === CODEGEN_VERSION
|
|
98
|
+
? `${out} was generated by another version of tscomfy, which read the snapshot differently.`
|
|
99
|
+
: `${out} was generated by @tscomfy/codegen ${manifest.codegen}; this is ${CODEGEN_VERSION}.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (drifted) {
|
|
103
|
+
lines.push(
|
|
104
|
+
`${source.label} differs from ${snapshotPath}${ignored}:`,
|
|
105
|
+
...formatDiff(diff, verbose),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const command = regenerateCommand({
|
|
109
|
+
flag: source.flag,
|
|
110
|
+
out,
|
|
111
|
+
exportName: manifest.exportName,
|
|
112
|
+
importExtension: manifest.importExtension,
|
|
113
|
+
// With --url, the server says again.
|
|
114
|
+
...(flags.has("from") && manifest.comfyVersion !== undefined
|
|
115
|
+
? { comfyVersion: manifest.comfyVersion }
|
|
116
|
+
: {}),
|
|
117
|
+
});
|
|
118
|
+
lines.push(`Run ${command} to update.`);
|
|
119
|
+
io.out(`${lines.join("\n")}\n`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The diff without option changes of upload and remote combos (docs/plan.md §6.5): their options
|
|
125
|
+
* are the server's files, which change with every upload, and the types take any string there.
|
|
126
|
+
* Also returns those inputs, as `Class.input`.
|
|
127
|
+
*/
|
|
128
|
+
function withoutRuntimeOptions(
|
|
129
|
+
diff: SchemaDiff,
|
|
130
|
+
current: Schema,
|
|
131
|
+
): { readonly diff: SchemaDiff; readonly runtime: readonly string[] } {
|
|
132
|
+
const runtime: string[] = [];
|
|
133
|
+
const changed: NodeChange[] = [];
|
|
134
|
+
for (const change of diff.changed) {
|
|
135
|
+
const inputs = current.nodes[change.classType]?.inputs ?? [];
|
|
136
|
+
const kept = change.inputsChanged.filter((input: InputChange) => {
|
|
137
|
+
const definition = inputs.find((each) => each.name === input.name);
|
|
138
|
+
const runtimeOnly =
|
|
139
|
+
!input.specChanged &&
|
|
140
|
+
definition?.kind === "combo" &&
|
|
141
|
+
(definition.upload || definition.remote);
|
|
142
|
+
if (runtimeOnly) runtime.push(`${change.classType}.${input.name}`);
|
|
143
|
+
return !runtimeOnly;
|
|
144
|
+
});
|
|
145
|
+
const rest = { ...change, inputsChanged: kept };
|
|
146
|
+
const empty =
|
|
147
|
+
kept.length === 0 &&
|
|
148
|
+
rest.inputsAdded.length === 0 &&
|
|
149
|
+
rest.inputsRemoved.length === 0 &&
|
|
150
|
+
!rest.outputsChanged &&
|
|
151
|
+
!rest.detailsChanged;
|
|
152
|
+
if (!empty) changed.push(rest);
|
|
153
|
+
}
|
|
154
|
+
return { diff: { ...diff, changed }, runtime };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The diff as indented lines: every list (nodes, inputs, options, changed nodes) cut to its
|
|
159
|
+
* first few entries unless `verbose`, so a server that differs a lot still gives short lines.
|
|
160
|
+
*/
|
|
161
|
+
function formatDiff(diff: SchemaDiff, verbose: boolean): string[] {
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
if (diff.added.length > 0) {
|
|
164
|
+
lines.push(
|
|
165
|
+
` + ${count(diff.added.length, "node")} added: ${listed(diff.added, verbose, SHOWN_NAMES)}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (diff.removed.length > 0) {
|
|
169
|
+
lines.push(
|
|
170
|
+
` - ${count(diff.removed.length, "node")} removed: ${listed(diff.removed, verbose, SHOWN_NAMES)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (diff.changed.length > 0) {
|
|
174
|
+
lines.push(` ~ ${count(diff.changed.length, "node")} changed:`);
|
|
175
|
+
const shown = verbose ? diff.changed : diff.changed.slice(0, SHOWN_NAMES);
|
|
176
|
+
for (const change of shown) lines.push(...formatChange(change, verbose));
|
|
177
|
+
if (shown.length < diff.changed.length) {
|
|
178
|
+
lines.push(` … and ${String(diff.changed.length - shown.length)} more${MORE}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return lines;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatChange(change: NodeChange, verbose: boolean): string[] {
|
|
185
|
+
const lines = [` ${change.classType}`];
|
|
186
|
+
if (change.inputsAdded.length > 0) {
|
|
187
|
+
lines.push(` inputs added: ${listed(change.inputsAdded, verbose, SHOWN_NAMES)}`);
|
|
188
|
+
}
|
|
189
|
+
if (change.inputsRemoved.length > 0) {
|
|
190
|
+
lines.push(` inputs removed: ${listed(change.inputsRemoved, verbose, SHOWN_NAMES)}`);
|
|
191
|
+
}
|
|
192
|
+
for (const input of change.inputsChanged) {
|
|
193
|
+
const options = (values: readonly (string | number | boolean)[]): string =>
|
|
194
|
+
listed(
|
|
195
|
+
values.map((option) => JSON.stringify(option)),
|
|
196
|
+
verbose,
|
|
197
|
+
SHOWN_OPTIONS,
|
|
198
|
+
);
|
|
199
|
+
const parts = [
|
|
200
|
+
...(input.optionsAdded.length > 0 ? [`+ ${options(input.optionsAdded)}`] : []),
|
|
201
|
+
...(input.optionsRemoved.length > 0 ? [`- ${options(input.optionsRemoved)}`] : []),
|
|
202
|
+
...(input.specChanged ? ["spec changed"] : []),
|
|
203
|
+
];
|
|
204
|
+
lines.push(` ${input.name}: ${parts.join("; ")}`);
|
|
205
|
+
}
|
|
206
|
+
if (change.outputsChanged) lines.push(" outputs changed");
|
|
207
|
+
if (change.detailsChanged)
|
|
208
|
+
lines.push(" details changed (name, category, description, flags or order)");
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `a, b, c`, or the first `limit` and `… and 4 more (--verbose shows all)` unless `verbose`. */
|
|
213
|
+
function listed(items: readonly string[], verbose: boolean, limit: number): string {
|
|
214
|
+
const shown = verbose ? items : items.slice(0, limit);
|
|
215
|
+
const rest = items.length - shown.length;
|
|
216
|
+
return rest > 0 ? `${shown.join(", ")}, … and ${String(rest)} more${MORE}` : shown.join(", ");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `a`, `a and b`, `a, b and c`. */
|
|
220
|
+
function list(items: readonly string[]): string {
|
|
221
|
+
return items.length <= 1
|
|
222
|
+
? items.join("")
|
|
223
|
+
: `${items.slice(0, -1).join(", ")} and ${items.at(-1) ?? ""}`;
|
|
224
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { CODEGEN_VERSION, exportNameProblem, generate } from "@tscomfy/codegen";
|
|
3
|
+
import { hashSchema } from "@tscomfy/core";
|
|
4
|
+
import { type Flags, HELP, parseFlags, required, value } from "./args.ts";
|
|
5
|
+
import { CliError, type Io } from "./io.ts";
|
|
6
|
+
import {
|
|
7
|
+
contentHash,
|
|
8
|
+
formatManifest,
|
|
9
|
+
MANIFEST,
|
|
10
|
+
type Manifest,
|
|
11
|
+
readManifest,
|
|
12
|
+
SNAPSHOT,
|
|
13
|
+
} from "./manifest.ts";
|
|
14
|
+
import { count, formatDiagnostic, reportDiagnostics, size } from "./report.ts";
|
|
15
|
+
import { loadSchema, readSource, type Source } from "./source.ts";
|
|
16
|
+
import { camelName, flagWords } from "./util.ts";
|
|
17
|
+
|
|
18
|
+
/** `tscomfy generate --help`. */
|
|
19
|
+
export const GENERATE_USAGE: string = `Usage: tscomfy generate (--url <server> | --from <object_info.json>) --out <dir> [options]
|
|
20
|
+
|
|
21
|
+
Writes <dir>/nodes.ts (types), <dir>/index.ts (the typed schema), <dir>/${SNAPSHOT}
|
|
22
|
+
(the raw response, for regenerating offline and for \`tscomfy check\`) and <dir>/${MANIFEST}
|
|
23
|
+
(what was generated, for \`tscomfy check\` and \`tscomfy transpile\`).
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--url <server> A ComfyUI server, e.g. http://127.0.0.1:8188. Only GET requests.
|
|
27
|
+
Credentials and the query are sent, never written or shown.
|
|
28
|
+
--from <file> A saved /object_info response instead of a server.
|
|
29
|
+
--out <dir> The directory to write into.
|
|
30
|
+
--name <identifier> The exported schema constant. Default: from the directory name,
|
|
31
|
+
camelCased (my-server → myServer).
|
|
32
|
+
--import-extension <ext> How index.ts imports nodes.ts: .js (default) or .ts.
|
|
33
|
+
--comfy-version <version> The ComfyUI version to record, with --from. Default: the one
|
|
34
|
+
recorded next to <file> when it is a snapshot generate wrote.
|
|
35
|
+
--verbose List every diagnostic.
|
|
36
|
+
--quiet Print errors only.
|
|
37
|
+
-h, --help Show this help.
|
|
38
|
+
|
|
39
|
+
The headers record the command that regenerates the files, for a POSIX shell in the directory
|
|
40
|
+
generate ran in. A value that starts with - is written --flag=<value>.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const FLAGS = [
|
|
44
|
+
{ name: "url", takesValue: true },
|
|
45
|
+
{ name: "from", takesValue: true },
|
|
46
|
+
{ name: "out", takesValue: true },
|
|
47
|
+
{ name: "name", takesValue: true },
|
|
48
|
+
{ name: "import-extension", takesValue: true },
|
|
49
|
+
{ name: "comfy-version", takesValue: true },
|
|
50
|
+
{ name: "verbose", takesValue: false },
|
|
51
|
+
{ name: "quiet", takesValue: false },
|
|
52
|
+
HELP,
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** `tscomfy generate`. */
|
|
56
|
+
export async function generateCommand(io: Io, args: readonly string[]): Promise<number> {
|
|
57
|
+
const flags = parseFlags(args, FLAGS);
|
|
58
|
+
if (flags.has("help")) {
|
|
59
|
+
io.out(GENERATE_USAGE);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const out = required(flags, "out");
|
|
63
|
+
const exportName = readName(flags, out);
|
|
64
|
+
const importExtension = readExtension(flags);
|
|
65
|
+
if (flags.has("comfy-version") && !flags.has("from")) {
|
|
66
|
+
throw new CliError("--comfy-version goes with --from; with --url it comes from the server.", 2);
|
|
67
|
+
}
|
|
68
|
+
if (flags.has("verbose") && flags.has("quiet")) {
|
|
69
|
+
throw new CliError("--verbose and --quiet don't go together.", 2);
|
|
70
|
+
}
|
|
71
|
+
const read = await readSource(io, flags);
|
|
72
|
+
const source = await withSnapshotVersion(io, flags, read);
|
|
73
|
+
const schema = loadSchema(source);
|
|
74
|
+
|
|
75
|
+
const command = regenerateCommand({
|
|
76
|
+
flag: source.flag,
|
|
77
|
+
out,
|
|
78
|
+
exportName,
|
|
79
|
+
importExtension,
|
|
80
|
+
// With --url, the server says again.
|
|
81
|
+
...(flags.has("from") && source.comfyVersion !== undefined
|
|
82
|
+
? { comfyVersion: source.comfyVersion }
|
|
83
|
+
: {}),
|
|
84
|
+
});
|
|
85
|
+
const result = generate(schema.schema, { exportName, importExtension, command });
|
|
86
|
+
const files = [...result.files, { path: SNAPSHOT, contents: source.text }];
|
|
87
|
+
await Promise.all(files.map((file) => io.writeText(join(out, file.path), file.contents)));
|
|
88
|
+
// Last: a run interrupted before this leaves a manifest that doesn't match the files.
|
|
89
|
+
const manifest: Manifest = {
|
|
90
|
+
codegen: CODEGEN_VERSION,
|
|
91
|
+
schema: hashSchema(schema.schema),
|
|
92
|
+
exportName,
|
|
93
|
+
importExtension,
|
|
94
|
+
...(source.comfyVersion === undefined ? {} : { comfyVersion: source.comfyVersion }),
|
|
95
|
+
files: Object.fromEntries(files.map((file) => [file.path, contentHash(file.contents)])),
|
|
96
|
+
};
|
|
97
|
+
await io.writeText(join(out, MANIFEST), formatManifest(manifest));
|
|
98
|
+
|
|
99
|
+
const diagnostics = [...schema.diagnostics, ...result.diagnostics];
|
|
100
|
+
if (!flags.has("quiet")) {
|
|
101
|
+
const version =
|
|
102
|
+
schema.schema.comfyVersion === undefined ? "" : ` (ComfyUI ${schema.schema.comfyVersion})`;
|
|
103
|
+
io.out(
|
|
104
|
+
`Generated ${count(Object.keys(schema.schema.nodes).length, "node type")} from ${source.label}${version} into ${out}:\n` +
|
|
105
|
+
` ${files.map((file) => `${file.path} ${size(byteLength(file.contents))}`).join(" · ")}\n`,
|
|
106
|
+
);
|
|
107
|
+
io.out(reportDiagnostics(diagnostics, schema.schema, source.text, flags.has("verbose")));
|
|
108
|
+
} else {
|
|
109
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
110
|
+
for (const diagnostic of errors) io.err(`${formatDiagnostic(diagnostic)}\n`);
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `--from` a snapshot generate wrote, without `--comfy-version`: the version its manifest
|
|
117
|
+
* records, when the file is exactly the one recorded. Regenerating offline then keeps the
|
|
118
|
+
* version a `--url` run fetched. Anything else (another file, an edited snapshot, no manifest,
|
|
119
|
+
* or one that isn't ours) leaves the version unknown, as for any saved response.
|
|
120
|
+
*/
|
|
121
|
+
async function withSnapshotVersion(io: Io, flags: Flags, source: Source): Promise<Source> {
|
|
122
|
+
const from = value(flags, "from");
|
|
123
|
+
if (from === undefined || source.comfyVersion !== undefined || basename(from) !== SNAPSHOT) {
|
|
124
|
+
return source;
|
|
125
|
+
}
|
|
126
|
+
const manifest = await readManifest(io, dirname(from)).catch(() => undefined);
|
|
127
|
+
const recorded = manifest?.comfyVersion;
|
|
128
|
+
if (recorded === undefined || manifest?.files[SNAPSHOT] !== contentHash(source.text)) {
|
|
129
|
+
return source;
|
|
130
|
+
}
|
|
131
|
+
return { ...source, comfyVersion: recorded };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What regenerates a directory: the recorded options, from a given source. */
|
|
135
|
+
export interface Regenerate {
|
|
136
|
+
/** `--url <server>` or `--from <file>`, as shell words. */
|
|
137
|
+
readonly flag: string;
|
|
138
|
+
readonly out: string;
|
|
139
|
+
readonly exportName: string;
|
|
140
|
+
readonly importExtension: ".js" | ".ts";
|
|
141
|
+
readonly comfyVersion?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The `tscomfy generate` command for a POSIX shell; options at their defaults are left out. */
|
|
145
|
+
export function regenerateCommand(options: Regenerate): string {
|
|
146
|
+
return [
|
|
147
|
+
"tscomfy generate",
|
|
148
|
+
options.flag,
|
|
149
|
+
flagWords("out", options.out),
|
|
150
|
+
...(options.exportName === defaultName(options.out)
|
|
151
|
+
? []
|
|
152
|
+
: [flagWords("name", options.exportName)]),
|
|
153
|
+
...(options.importExtension === ".js" ? [] : [`--import-extension ${options.importExtension}`]),
|
|
154
|
+
...(options.comfyVersion === undefined
|
|
155
|
+
? []
|
|
156
|
+
: [flagWords("comfy-version", options.comfyVersion)]),
|
|
157
|
+
].join(" ");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The export name from the output directory (see {@link camelName}), if it can be one. */
|
|
161
|
+
function defaultName(out: string): string | undefined {
|
|
162
|
+
const name = camelName(basename(out));
|
|
163
|
+
return exportNameProblem(name) === undefined ? name : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readName(flags: Flags, out: string): string {
|
|
167
|
+
const given = value(flags, "name");
|
|
168
|
+
if (given === undefined) {
|
|
169
|
+
const derived = defaultName(out);
|
|
170
|
+
if (derived === undefined) {
|
|
171
|
+
const name = camelName(basename(out));
|
|
172
|
+
const why =
|
|
173
|
+
name === "" ? "" : `: ${JSON.stringify(name)} won't do, ${exportNameProblem(name) ?? ""}`;
|
|
174
|
+
throw new CliError(
|
|
175
|
+
`Can't make an export name from ${JSON.stringify(out)}${why}; pass --name.`,
|
|
176
|
+
2,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return derived;
|
|
180
|
+
}
|
|
181
|
+
const problem = exportNameProblem(given);
|
|
182
|
+
if (problem !== undefined) {
|
|
183
|
+
throw new CliError(`--name ${JSON.stringify(given)} isn't usable: ${problem}.`, 2);
|
|
184
|
+
}
|
|
185
|
+
return given;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readExtension(flags: Flags): ".js" | ".ts" {
|
|
189
|
+
const extension = value(flags, "import-extension") ?? ".js";
|
|
190
|
+
if (extension !== ".js" && extension !== ".ts") {
|
|
191
|
+
throw new CliError("--import-extension is .js or .ts.", 2);
|
|
192
|
+
}
|
|
193
|
+
return extension;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function byteLength(text: string): number {
|
|
197
|
+
return new TextEncoder().encode(text).length;
|
|
198
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @tscomfy/cli
|
|
3
|
+
*
|
|
4
|
+
* The `tscomfy` command: generate types from a ComfyUI server, check them for drift against a
|
|
5
|
+
* snapshot, and transpile API prompts and UI workflows to code. The only package with I/O,
|
|
6
|
+
* through `node:fs`, `node:crypto` and `fetch`, so it runs on Node and Bun.
|
|
7
|
+
* Exported for running the CLI in-process: `run(args, systemIo())`, or with an {@link Io} of
|
|
8
|
+
* your own, which reports failures as {@link CliError}s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { CliError, type Io, systemIo } from "./io.ts";
|
|
12
|
+
export { CLI_VERSION, run } from "./run.ts";
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { errorBodyMessage, redactUrl } from "./util.ts";
|
|
4
|
+
|
|
5
|
+
/** Everything the CLI does to the outside world, so commands can be tested without it. */
|
|
6
|
+
export interface Io {
|
|
7
|
+
/** Writes to standard output. */
|
|
8
|
+
readonly out: (text: string) => void;
|
|
9
|
+
/** Writes to standard error. */
|
|
10
|
+
readonly err: (text: string) => void;
|
|
11
|
+
/**
|
|
12
|
+
* GETs a URL's body; throws a {@link CliError} for a network error or a non-2xx status (with
|
|
13
|
+
* the message of an error body, such as `{"detail": "Not authenticated"}`).
|
|
14
|
+
*/
|
|
15
|
+
readonly fetchText: (url: string) => Promise<string>;
|
|
16
|
+
/** A file's text (UTF-8, without a byte order mark), or `undefined` when it doesn't exist. */
|
|
17
|
+
readonly readText: (path: string) => Promise<string | undefined>;
|
|
18
|
+
/** Replaces a file's contents in one step, creating its directory. */
|
|
19
|
+
readonly writeText: (path: string, text: string) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A failure to report to the user: a message, and the exit code to end with. */
|
|
23
|
+
export class CliError extends Error {
|
|
24
|
+
/** 1 for a failure, 2 for a usage error. */
|
|
25
|
+
readonly exitCode: number;
|
|
26
|
+
|
|
27
|
+
/** A failure with `message`, ending the process with `exitCode`. */
|
|
28
|
+
constructor(message: string, exitCode = 1) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "CliError";
|
|
31
|
+
this.exitCode = exitCode;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FETCH_TIMEOUT_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
/** The real world: the process's streams, `fetch` and `node:fs`, which Node and Bun both have. */
|
|
38
|
+
export function systemIo(): Io {
|
|
39
|
+
return {
|
|
40
|
+
out: (text) => {
|
|
41
|
+
process.stdout.write(text);
|
|
42
|
+
},
|
|
43
|
+
err: (text) => {
|
|
44
|
+
process.stderr.write(text);
|
|
45
|
+
},
|
|
46
|
+
fetchText: async (url) => {
|
|
47
|
+
const shown = redactUrl(new URL(url));
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const answer = `${String(response.status)} ${response.statusText}`.trimEnd();
|
|
52
|
+
// Best effort: the status says what went wrong; the body may say why.
|
|
53
|
+
const body = await response.text().catch(() => "");
|
|
54
|
+
const detail = errorBodyMessage(body);
|
|
55
|
+
throw new CliError(
|
|
56
|
+
`${shown} answered ${answer}${detail === undefined ? "" : `: ${detail}`}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return await response.text();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof CliError) throw error;
|
|
62
|
+
throw new CliError(`Could not reach ${shown}: ${reason(error)}`);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
readText: async (path) => {
|
|
66
|
+
try {
|
|
67
|
+
// TextDecoder drops a byte order mark, as editors that save one expect.
|
|
68
|
+
return new TextDecoder().decode(await readFile(path));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (codeOf(error) === "ENOENT") return undefined;
|
|
71
|
+
throw new CliError(`Could not read ${path}: ${reason(error)}`);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
writeText: async (path, text) => {
|
|
75
|
+
// Through a temporary file, so an interrupted run never leaves a file half written. One
|
|
76
|
+
// name per file, so a run that was killed leaves nothing a later run doesn't replace; and
|
|
77
|
+
// a symlink is followed, so the file it points to is the one replaced.
|
|
78
|
+
const target = await realpath(path).catch(() => path);
|
|
79
|
+
const temporary = `${target}.tmp`;
|
|
80
|
+
try {
|
|
81
|
+
await mkdir(dirname(target), { recursive: true });
|
|
82
|
+
await writeFile(temporary, text);
|
|
83
|
+
await rename(temporary, target);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// Best effort: when the directory can't be made, there is nothing to remove.
|
|
86
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
87
|
+
throw new CliError(`Could not write ${path}: ${reason(error)}`);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function reason(error: unknown): string {
|
|
94
|
+
return error instanceof Error ? error.message : String(error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function codeOf(error: unknown): unknown {
|
|
98
|
+
return typeof error === "object" && error !== null ? Reflect.get(error, "code") : undefined;
|
|
99
|
+
}
|
package/src/main.ts
ADDED
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Schema } from "@tscomfy/core";
|
|
4
|
+
import { CliError, type Io } from "./io.ts";
|
|
5
|
+
import { loadSchema } from "./source.ts";
|
|
6
|
+
|
|
7
|
+
/** The record `generate` writes last, next to the files, for `check` and `transpile`. */
|
|
8
|
+
export const MANIFEST = "tscomfy.json";
|
|
9
|
+
|
|
10
|
+
/** The raw snapshot written next to the generated files, for `--from` and `check`. */
|
|
11
|
+
export const SNAPSHOT = "object_info.json";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What one `generate` run wrote: the codegen version, the schema hash, the options needed to
|
|
15
|
+
* regenerate, and a content hash of every file, so `check` notices an edited, missing or
|
|
16
|
+
* half-written file and output from another version.
|
|
17
|
+
*/
|
|
18
|
+
export interface Manifest {
|
|
19
|
+
readonly codegen: string;
|
|
20
|
+
/** `hashSchema` of the snapshot (D15). */
|
|
21
|
+
readonly schema: string;
|
|
22
|
+
readonly exportName: string;
|
|
23
|
+
readonly importExtension: ".js" | ".ts";
|
|
24
|
+
/**
|
|
25
|
+
* The ComfyUI version of the snapshot, when known: the server's with `--url`, the
|
|
26
|
+
* `--comfy-version` given with `--from`. For regenerating from the snapshot, and for the
|
|
27
|
+
* command `check` suggests with `--from`.
|
|
28
|
+
*/
|
|
29
|
+
readonly comfyVersion?: string;
|
|
30
|
+
/** `sha256:<hex>` of each file, by its name in the output directory: exactly {@link FILES}. */
|
|
31
|
+
readonly files: { readonly [file: string]: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The files a manifest covers, in the order `check` reports them. */
|
|
35
|
+
export const FILES: readonly string[] = ["nodes.ts", "index.ts", SNAPSHOT];
|
|
36
|
+
|
|
37
|
+
/** `sha256:<hex>` of a text's UTF-8 bytes. */
|
|
38
|
+
export function contentHash(text: string): string {
|
|
39
|
+
return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The manifest's file contents: stable key order, one trailing newline. */
|
|
43
|
+
export function formatManifest(manifest: Manifest): string {
|
|
44
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The manifest in `out`, or `undefined` when there is none. Throws for one that isn't ours. */
|
|
48
|
+
export async function readManifest(io: Io, out: string): Promise<Manifest | undefined> {
|
|
49
|
+
const path = join(out, MANIFEST);
|
|
50
|
+
const text = await io.readText(path);
|
|
51
|
+
if (text === undefined) return undefined;
|
|
52
|
+
let value: unknown;
|
|
53
|
+
try {
|
|
54
|
+
value = JSON.parse(text);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new CliError(`${path} is not JSON; run tscomfy generate again.`);
|
|
57
|
+
}
|
|
58
|
+
if (!isManifest(value)) {
|
|
59
|
+
throw new CliError(`${path} is not a tscomfy manifest; run tscomfy generate again.`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isManifest(value: unknown): value is Manifest {
|
|
65
|
+
if (typeof value !== "object" || value === null) return false;
|
|
66
|
+
const field = (name: string): unknown => Reflect.get(value, name);
|
|
67
|
+
const files = field("files");
|
|
68
|
+
const extension = field("importExtension");
|
|
69
|
+
const comfyVersion = field("comfyVersion");
|
|
70
|
+
return (
|
|
71
|
+
typeof field("codegen") === "string" &&
|
|
72
|
+
typeof field("schema") === "string" &&
|
|
73
|
+
typeof field("exportName") === "string" &&
|
|
74
|
+
(extension === ".js" || extension === ".ts") &&
|
|
75
|
+
(comfyVersion === undefined || typeof comfyVersion === "string") &&
|
|
76
|
+
typeof files === "object" &&
|
|
77
|
+
files !== null &&
|
|
78
|
+
// Exactly our files: a hand-edited list must not hide one, or name a path outside `out`.
|
|
79
|
+
Object.keys(files).toSorted().join("\n") === FILES.toSorted().join("\n") &&
|
|
80
|
+
Object.values(files).every((hash) => typeof hash === "string")
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A directory `tscomfy generate` wrote: its manifest, and the snapshot normalized. */
|
|
85
|
+
export interface Generated {
|
|
86
|
+
readonly manifest: Manifest;
|
|
87
|
+
readonly snapshotPath: string;
|
|
88
|
+
/** The snapshot's text, exactly as written. */
|
|
89
|
+
readonly snapshotText: string;
|
|
90
|
+
readonly schema: Schema;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The manifest and snapshot in `dir`; fails when either is missing, or isn't ours. */
|
|
94
|
+
export async function readGenerated(io: Io, dir: string): Promise<Generated> {
|
|
95
|
+
const manifest = await readManifest(io, dir);
|
|
96
|
+
const snapshotPath = join(dir, SNAPSHOT);
|
|
97
|
+
const snapshotText = await io.readText(snapshotPath);
|
|
98
|
+
if (manifest === undefined || snapshotText === undefined) {
|
|
99
|
+
throw new CliError(
|
|
100
|
+
`There is no ${manifest === undefined ? MANIFEST : SNAPSHOT} in ${dir}; run tscomfy generate first.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const { schema } = loadSchema({ text: snapshotText, label: snapshotPath, flag: "" });
|
|
104
|
+
return { manifest, snapshotPath, snapshotText, schema };
|
|
105
|
+
}
|