gui4cli 0.0.0 → 0.0.2
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/README.md +74 -2
- package/bin/gui4cli.js +16 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +86 -0
- package/dist/config/load.d.ts +13 -0
- package/dist/config/load.js +66 -0
- package/dist/detect/ast.d.ts +6 -0
- package/dist/detect/ast.js +62 -0
- package/dist/detect/commander.d.ts +2 -0
- package/dist/detect/commander.js +45 -0
- package/dist/detect/flags.d.ts +25 -0
- package/dist/detect/flags.js +77 -0
- package/dist/detect/help.d.ts +3 -0
- package/dist/detect/help.js +53 -0
- package/dist/detect/index.d.ts +2 -0
- package/dist/detect/index.js +61 -0
- package/dist/detect/jsdoc.d.ts +2 -0
- package/dist/detect/jsdoc.js +22 -0
- package/dist/detect/yargs.d.ts +2 -0
- package/dist/detect/yargs.js +75 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +17 -0
- package/dist/generate/project.d.ts +10 -0
- package/dist/generate/project.js +77 -0
- package/dist/generate/window-app.d.ts +2 -0
- package/dist/generate/window-app.js +282 -0
- package/dist/generate/window.d.ts +9 -0
- package/dist/generate/window.js +56 -0
- package/dist/last-run/store.d.ts +4 -0
- package/dist/last-run/store.js +23 -0
- package/dist/resolve/target.d.ts +4 -0
- package/dist/resolve/target.js +54 -0
- package/dist/run/argv.d.ts +5 -0
- package/dist/run/argv.js +59 -0
- package/dist/run/kill.d.ts +1 -0
- package/dist/run/kill.js +18 -0
- package/dist/schema/form.d.ts +139 -0
- package/dist/schema/form.js +51 -0
- package/dist/window/env.d.ts +1 -0
- package/dist/window/env.js +14 -0
- package/dist/window/open.d.ts +2 -0
- package/dist/window/open.js +44 -0
- package/dist/window/resolve.d.ts +6 -0
- package/dist/window/resolve.js +49 -0
- package/package.json +26 -4
- package/bin/argui.js +0 -4
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { objectProps, parseSource, primitiveLiteral, stringLiteral, walkCalls } from "./ast.js";
|
|
3
|
+
import { fieldFromFlag, nameFromLongFlag, shouldSkipFlag } from "./flags.js";
|
|
4
|
+
const YARGS_TYPES = new Set(["string", "number", "boolean", "array"]);
|
|
5
|
+
export function detectYargs(fileName, source) {
|
|
6
|
+
const sf = parseSource(fileName, source);
|
|
7
|
+
const fields = [];
|
|
8
|
+
walkCalls(sf, (call, method) => {
|
|
9
|
+
if (method !== "option" && method !== "options" && method !== "positional")
|
|
10
|
+
return;
|
|
11
|
+
if (method === "options") {
|
|
12
|
+
const props = objectProps(call.arguments[0]);
|
|
13
|
+
for (const [name, value] of props) {
|
|
14
|
+
const field = fieldFromYargs(name, objectProps(value), false);
|
|
15
|
+
if (field)
|
|
16
|
+
fields.push(field);
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const name = stringLiteral(call.arguments[0]);
|
|
21
|
+
if (!name || shouldSkipFlag(name))
|
|
22
|
+
return;
|
|
23
|
+
const field = fieldFromYargs(name, objectProps(call.arguments[1]), method === "positional");
|
|
24
|
+
if (field)
|
|
25
|
+
fields.push(field);
|
|
26
|
+
});
|
|
27
|
+
return uniqueFields(fields);
|
|
28
|
+
}
|
|
29
|
+
function fieldFromYargs(rawName, props, positional) {
|
|
30
|
+
const name = nameFromLongFlag(rawName.startsWith("-") ? rawName : `--${rawName}`);
|
|
31
|
+
if (shouldSkipFlag(name))
|
|
32
|
+
return null;
|
|
33
|
+
const typeText = stringLiteral(props.get("type"));
|
|
34
|
+
const type = typeText && YARGS_TYPES.has(typeText)
|
|
35
|
+
? typeText === "array"
|
|
36
|
+
? "string"
|
|
37
|
+
: typeText
|
|
38
|
+
: undefined;
|
|
39
|
+
const alias = stringLiteral(props.get("alias"));
|
|
40
|
+
const help = stringLiteral(props.get("describe") ?? props.get("description") ?? props.get("desc"));
|
|
41
|
+
const required = primitiveLiteral(props.get("demandOption")) === true ||
|
|
42
|
+
primitiveLiteral(props.get("required")) === true ||
|
|
43
|
+
primitiveLiteral(props.get("demand")) === true;
|
|
44
|
+
const defaultValue = primitiveLiteral(props.get("default"));
|
|
45
|
+
const choices = stringArray(props.get("choices"));
|
|
46
|
+
return fieldFromFlag({
|
|
47
|
+
name,
|
|
48
|
+
longFlag: `--${name}`,
|
|
49
|
+
shortFlag: alias ? (alias.startsWith("-") ? alias : `-${alias}`) : undefined,
|
|
50
|
+
type,
|
|
51
|
+
help,
|
|
52
|
+
required: required || positional,
|
|
53
|
+
default: defaultValue,
|
|
54
|
+
choices,
|
|
55
|
+
boolean: type === "boolean",
|
|
56
|
+
positional,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function stringArray(node) {
|
|
60
|
+
if (!node || !ts.isArrayLiteralExpression(node))
|
|
61
|
+
return undefined;
|
|
62
|
+
const values = node.elements
|
|
63
|
+
.map((el) => stringLiteral(el))
|
|
64
|
+
.filter((value) => Boolean(value));
|
|
65
|
+
return values.length > 0 ? values : undefined;
|
|
66
|
+
}
|
|
67
|
+
function uniqueFields(fields) {
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
return fields.filter((field) => {
|
|
70
|
+
if (seen.has(field.name))
|
|
71
|
+
return false;
|
|
72
|
+
seen.add(field.name);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class Gui4CliError extends Error {
|
|
2
|
+
detail;
|
|
3
|
+
constructor(message, detail) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "Gui4CliError";
|
|
6
|
+
this.detail = detail;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function formatUserError(error) {
|
|
10
|
+
if (error instanceof Gui4CliError) {
|
|
11
|
+
return error.detail ? `${error.message}\n${error.detail}` : error.message;
|
|
12
|
+
}
|
|
13
|
+
if (error instanceof Error) {
|
|
14
|
+
return error.message;
|
|
15
|
+
}
|
|
16
|
+
return String(error);
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FormSpec, FormValues } from "../schema/form.js";
|
|
2
|
+
export declare function slugify(title: string): string;
|
|
3
|
+
export declare function defaultBuildDir(title: string, cwd: string): string;
|
|
4
|
+
export declare function writeBuildProject(input: {
|
|
5
|
+
spec: FormSpec;
|
|
6
|
+
values: FormValues;
|
|
7
|
+
outDir: string;
|
|
8
|
+
lastRunPath: string;
|
|
9
|
+
force?: boolean;
|
|
10
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { Gui4CliError } from "../errors.js";
|
|
5
|
+
import { writeWindowApp } from "./window.js";
|
|
6
|
+
export function slugify(title) {
|
|
7
|
+
const slug = title
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
10
|
+
.replace(/^-+|-+$/g, "");
|
|
11
|
+
return slug || "app";
|
|
12
|
+
}
|
|
13
|
+
export function defaultBuildDir(title, cwd) {
|
|
14
|
+
return resolve(cwd, `${slugify(title)}-gui`);
|
|
15
|
+
}
|
|
16
|
+
export async function writeBuildProject(input) {
|
|
17
|
+
const outDir = resolve(input.outDir);
|
|
18
|
+
await prepareOutDir(outDir, input.force === true);
|
|
19
|
+
const payload = {
|
|
20
|
+
spec: input.spec,
|
|
21
|
+
values: input.values,
|
|
22
|
+
nodePath: process.execPath,
|
|
23
|
+
lastRunPath: input.lastRunPath,
|
|
24
|
+
platform: process.platform,
|
|
25
|
+
};
|
|
26
|
+
await writeWindowApp(payload, outDir);
|
|
27
|
+
const width = input.spec.window?.width ?? 960;
|
|
28
|
+
const height = input.spec.window?.height ?? 620;
|
|
29
|
+
const pkgName = slugify(input.spec.title);
|
|
30
|
+
await writeFile(join(outDir, "package.json"), `${JSON.stringify({
|
|
31
|
+
name: pkgName,
|
|
32
|
+
private: true,
|
|
33
|
+
type: "module",
|
|
34
|
+
scripts: {
|
|
35
|
+
start: `npx --yes windowd --title ${JSON.stringify(input.spec.title)} --width ${width} --height ${height}`,
|
|
36
|
+
},
|
|
37
|
+
}, null, 2)}\n`, "utf8");
|
|
38
|
+
await writeFile(join(outDir, "runner.js"), `// GUI4CLI does not rewrite the original script.\nexport const scriptPath = ${JSON.stringify(input.spec.target)};\n`, "utf8");
|
|
39
|
+
await writeFile(join(outDir, "README.md"), projectReadme(input.spec), "utf8");
|
|
40
|
+
return outDir;
|
|
41
|
+
}
|
|
42
|
+
async function prepareOutDir(outDir, force) {
|
|
43
|
+
if (existsSync(outDir)) {
|
|
44
|
+
const entries = await readdir(outDir).catch(() => null);
|
|
45
|
+
if (entries === null) {
|
|
46
|
+
throw new Gui4CliError(`Could not write the project folder.`, `${outDir} already exists and is not a folder.`);
|
|
47
|
+
}
|
|
48
|
+
if (entries.length > 0 && !force) {
|
|
49
|
+
throw new Gui4CliError(`The folder ${outDir} already exists.`, "Choose another --out path, or pass --force to replace it.");
|
|
50
|
+
}
|
|
51
|
+
if (force) {
|
|
52
|
+
await rm(outDir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await mkdir(outDir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
function projectReadme(spec) {
|
|
58
|
+
const script = basename(spec.target);
|
|
59
|
+
return `# ${spec.title}
|
|
60
|
+
|
|
61
|
+
Desktop form generated by GUI4CLI. This folder is the GUI shell. It runs **${script}** in place and does not rewrite that file.
|
|
62
|
+
|
|
63
|
+
\`\`\`bash
|
|
64
|
+
npx --yes windowd
|
|
65
|
+
# or
|
|
66
|
+
npm start
|
|
67
|
+
\`\`\`
|
|
68
|
+
|
|
69
|
+
The first windowd launch may download the NW.js runtime (~200 MB).
|
|
70
|
+
|
|
71
|
+
If you move this folder, keep the original script at:
|
|
72
|
+
|
|
73
|
+
\`${spec.target}\`
|
|
74
|
+
|
|
75
|
+
A real \`.exe\` is not part of this step.
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const windowAppJs = "import { spawn } from \"node:child_process\";\nimport { writeFileSync } from \"node:fs\";\nimport { payload } from \"./payload.js\";\n\nconst statusEl = document.getElementById(\"status\");\nconst formEl = document.getElementById(\"form\");\nconst previewEl = document.getElementById(\"preview\");\nconst outputEl = document.getElementById(\"output\");\nconst runBtn = document.getElementById(\"run\");\nconst cancelBtn = document.getElementById(\"cancel\");\nconst resultEl = document.getElementById(\"result\");\n\nconst values = { ...payload.values };\nlet child = null;\nlet startedAt = 0;\n\nfunction setStatus(phase, text) {\n statusEl.dataset.phase = phase;\n statusEl.textContent = text;\n}\n\nfunction quote(part) {\n return /[\\s\"]/.test(part) ? '\"' + part.replaceAll('\"', '\\\\\"') + '\"' : part;\n}\n\nfunction buildArgv() {\n const argv = [];\n for (const field of payload.spec.fields) {\n const value = values[field.name];\n if (field.positional) {\n if (value !== undefined && value !== \"\" && value !== false) argv.push(String(value));\n continue;\n }\n if (field.type === \"boolean\") {\n if (value === true) argv.push(field.longFlag);\n continue;\n }\n if (value === undefined || value === \"\") continue;\n argv.push(field.longFlag, String(value));\n }\n return argv;\n}\n\nfunction updatePreview() {\n const argv = buildArgv();\n previewEl.textContent = [process.execPath, payload.spec.target, ...argv].map(quote).join(\" \")\n + \"\\n(in \" + payload.spec.cwd + \")\";\n}\n\nfunction missingRequired() {\n return payload.spec.fields\n .filter((field) => field.required && field.type !== \"boolean\")\n .filter((field) => values[field.name] === undefined || values[field.name] === \"\")\n .map((field) => field.label);\n}\n\nfunction fieldControl(field) {\n const wrap = document.createElement(\"label\");\n wrap.className = \"field\";\n const title = document.createElement(\"span\");\n title.className = \"field-label\";\n title.textContent = field.label + (field.required ? \" (required)\" : \"\");\n wrap.appendChild(title);\n if (field.help) {\n const help = document.createElement(\"span\");\n help.className = \"field-help\";\n help.textContent = field.help;\n wrap.appendChild(help);\n }\n\n let input;\n if (field.type === \"boolean\") {\n input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.checked = Boolean(values[field.name]);\n input.addEventListener(\"change\", () => {\n values[field.name] = input.checked;\n updatePreview();\n });\n } else if (field.type === \"choice\" && field.choices) {\n input = document.createElement(\"select\");\n for (const choice of field.choices) {\n const opt = document.createElement(\"option\");\n opt.value = choice;\n opt.textContent = choice;\n if (String(values[field.name]) === choice) opt.selected = true;\n input.appendChild(opt);\n }\n input.addEventListener(\"change\", () => {\n values[field.name] = input.value;\n updatePreview();\n });\n } else {\n input = document.createElement(\"input\");\n input.type = field.type === \"number\" ? \"number\" : \"text\";\n input.value = values[field.name] === undefined ? \"\" : String(values[field.name]);\n input.placeholder = field.type === \"directory\" || field.type === \"file\" ? \"Path\" : \"\";\n input.addEventListener(\"input\", () => {\n values[field.name] = field.type === \"number\" && input.value !== \"\" ? Number(input.value) : input.value;\n updatePreview();\n });\n }\n input.id = \"field-\" + field.name;\n wrap.appendChild(input);\n return wrap;\n}\n\nfunction appendOutput(stream, text) {\n const span = document.createElement(\"span\");\n span.className = stream;\n span.textContent = text;\n outputEl.appendChild(span);\n outputEl.scrollTop = outputEl.scrollHeight;\n}\n\nfunction killTree(pid) {\n if (payload.platform === \"win32\") {\n spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { stdio: \"ignore\", windowsHide: true });\n return;\n }\n try { process.kill(-pid, \"SIGTERM\"); } catch { try { process.kill(pid, \"SIGTERM\"); } catch {} }\n}\n\nfunction finish(code) {\n const ms = Date.now() - startedAt;\n child = null;\n runBtn.disabled = false;\n cancelBtn.disabled = true;\n const phase = code === 0 ? \"done\" : \"failed\";\n setStatus(phase, code === 0 ? \"Done\" : \"Failed\");\n resultEl.textContent = \"Exit code \" + code + \" \u00B7 \" + (ms / 1000).toFixed(1) + \"s\";\n}\n\nfunction run() {\n const missing = missingRequired();\n if (missing.length) {\n setStatus(\"failed\", \"Missing required fields\");\n resultEl.textContent = \"Fill in: \" + missing.join(\", \");\n return;\n }\n const argv = buildArgv();\n outputEl.replaceChildren();\n resultEl.textContent = \"\";\n setStatus(\"running\", \"Running\");\n runBtn.disabled = true;\n cancelBtn.disabled = false;\n startedAt = Date.now();\n try {\n writeFileSync(payload.lastRunPath, JSON.stringify(values, null, 2) + \"\\n\");\n } catch {}\n\n child = spawn(process.execPath, [payload.spec.target, ...argv], {\n cwd: payload.spec.cwd,\n env: process.env,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n detached: payload.platform !== \"win32\",\n });\n child.stdout.on(\"data\", (chunk) => {\n setStatus(\"streaming\", \"Streaming output\");\n appendOutput(\"stdout\", chunk.toString(\"utf8\"));\n });\n child.stderr.on(\"data\", (chunk) => {\n setStatus(\"streaming\", \"Streaming output\");\n appendOutput(\"stderr\", chunk.toString(\"utf8\"));\n });\n child.on(\"error\", (error) => {\n appendOutput(\"stderr\", error.message + \"\\n\");\n finish(1);\n });\n child.on(\"close\", (code) => finish(code ?? 1));\n}\n\nfunction cancel() {\n if (!child || child.pid == null) return;\n killTree(child.pid);\n appendOutput(\"stderr\", \"\\nCancelled.\\n\");\n}\n\nfor (const field of payload.spec.fields) {\n formEl.appendChild(fieldControl(field));\n}\nupdatePreview();\nsetStatus(\"ready\", \"Ready \u2014 review the command, then Run\");\nrunBtn.addEventListener(\"click\", run);\ncancelBtn.addEventListener(\"click\", cancel);\ndocument.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) run();\n});\n";
|
|
2
|
+
export declare const windowStyles = ":root {\n color-scheme: light;\n --bg: #f6f4ef;\n --surface: #fffdf8;\n --ink: #1f1b16;\n --muted: #5c564d;\n --line: #d9d2c5;\n --accent: #2f5d50;\n --danger: #8a2f2f;\n --ok: #2f5d50;\n}\n* { box-sizing: border-box; }\nhtml, body { margin: 0; height: 100%; overflow: hidden; }\nbody {\n font: 14px/1.4 \"Segoe UI\", system-ui, sans-serif;\n background: var(--bg);\n color: var(--ink);\n display: flex;\n flex-direction: column;\n}\nheader {\n display: flex;\n justify-content: space-between;\n gap: 16px;\n align-items: baseline;\n padding: 10px 16px;\n border-bottom: 1px solid var(--line);\n background: var(--surface);\n flex-shrink: 0;\n}\nh1 { margin: 0; font-size: 1.1rem; }\nh2 { margin: 0 0 6px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }\n.desc, .muted { color: var(--muted); margin: 2px 0 0; }\n#status { margin: 0; white-space: nowrap; }\nmain {\n flex: 1;\n min-height: 0;\n display: grid;\n grid-template-columns: minmax(240px, 1fr) minmax(320px, 1.2fr);\n gap: 12px;\n padding: 12px 16px 16px;\n}\n.form-pane, .run-pane {\n min-height: 0;\n min-width: 0;\n overflow: auto;\n}\n.run-pane {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.field { display: flex; flex-direction: column; gap: 2px; margin-bottom: 10px; }\n.field-label { font-weight: 600; }\n.field-help { color: var(--muted); font-size: 0.8rem; }\ninput, select {\n font: inherit;\n padding: 6px 8px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: #fff;\n}\ninput[type=\"checkbox\"] { width: 1.1rem; height: 1.1rem; }\n.preview, .output {\n background: #1f1b16;\n color: #f6f4ef;\n border-radius: 8px;\n padding: 8px 10px;\n overflow: auto;\n white-space: pre-wrap;\n font: 12px/1.4 ui-monospace, Consolas, monospace;\n}\n.preview { max-height: 4.6em; flex-shrink: 0; }\n.output { flex: 1; min-height: 120px; }\n.output .stderr { color: #f0b4b4; }\n.actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }\n@media (max-width: 720px) {\n main { grid-template-columns: 1fr; }\n}\nbutton {\n font: inherit;\n border: 0;\n border-radius: 6px;\n padding: 8px 14px;\n cursor: pointer;\n}\n#run { background: var(--accent); color: #fff; }\n#cancel { background: #ece7dc; color: var(--ink); }\n#cancel:disabled, #run:disabled { opacity: 0.5; cursor: not-allowed; }\n#status[data-phase=\"running\"], #status[data-phase=\"streaming\"] { color: var(--accent); }\n#status[data-phase=\"done\"] { color: var(--ok); }\n#status[data-phase=\"failed\"] { color: var(--danger); }\n";
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
export const windowAppJs = `import { spawn } from "node:child_process";
|
|
2
|
+
import { writeFileSync } from "node:fs";
|
|
3
|
+
import { payload } from "./payload.js";
|
|
4
|
+
|
|
5
|
+
const statusEl = document.getElementById("status");
|
|
6
|
+
const formEl = document.getElementById("form");
|
|
7
|
+
const previewEl = document.getElementById("preview");
|
|
8
|
+
const outputEl = document.getElementById("output");
|
|
9
|
+
const runBtn = document.getElementById("run");
|
|
10
|
+
const cancelBtn = document.getElementById("cancel");
|
|
11
|
+
const resultEl = document.getElementById("result");
|
|
12
|
+
|
|
13
|
+
const values = { ...payload.values };
|
|
14
|
+
let child = null;
|
|
15
|
+
let startedAt = 0;
|
|
16
|
+
|
|
17
|
+
function setStatus(phase, text) {
|
|
18
|
+
statusEl.dataset.phase = phase;
|
|
19
|
+
statusEl.textContent = text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function quote(part) {
|
|
23
|
+
return /[\\s"]/.test(part) ? '"' + part.replaceAll('"', '\\\\"') + '"' : part;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildArgv() {
|
|
27
|
+
const argv = [];
|
|
28
|
+
for (const field of payload.spec.fields) {
|
|
29
|
+
const value = values[field.name];
|
|
30
|
+
if (field.positional) {
|
|
31
|
+
if (value !== undefined && value !== "" && value !== false) argv.push(String(value));
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (field.type === "boolean") {
|
|
35
|
+
if (value === true) argv.push(field.longFlag);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (value === undefined || value === "") continue;
|
|
39
|
+
argv.push(field.longFlag, String(value));
|
|
40
|
+
}
|
|
41
|
+
return argv;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function updatePreview() {
|
|
45
|
+
const argv = buildArgv();
|
|
46
|
+
previewEl.textContent = [process.execPath, payload.spec.target, ...argv].map(quote).join(" ")
|
|
47
|
+
+ "\\n(in " + payload.spec.cwd + ")";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function missingRequired() {
|
|
51
|
+
return payload.spec.fields
|
|
52
|
+
.filter((field) => field.required && field.type !== "boolean")
|
|
53
|
+
.filter((field) => values[field.name] === undefined || values[field.name] === "")
|
|
54
|
+
.map((field) => field.label);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fieldControl(field) {
|
|
58
|
+
const wrap = document.createElement("label");
|
|
59
|
+
wrap.className = "field";
|
|
60
|
+
const title = document.createElement("span");
|
|
61
|
+
title.className = "field-label";
|
|
62
|
+
title.textContent = field.label + (field.required ? " (required)" : "");
|
|
63
|
+
wrap.appendChild(title);
|
|
64
|
+
if (field.help) {
|
|
65
|
+
const help = document.createElement("span");
|
|
66
|
+
help.className = "field-help";
|
|
67
|
+
help.textContent = field.help;
|
|
68
|
+
wrap.appendChild(help);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let input;
|
|
72
|
+
if (field.type === "boolean") {
|
|
73
|
+
input = document.createElement("input");
|
|
74
|
+
input.type = "checkbox";
|
|
75
|
+
input.checked = Boolean(values[field.name]);
|
|
76
|
+
input.addEventListener("change", () => {
|
|
77
|
+
values[field.name] = input.checked;
|
|
78
|
+
updatePreview();
|
|
79
|
+
});
|
|
80
|
+
} else if (field.type === "choice" && field.choices) {
|
|
81
|
+
input = document.createElement("select");
|
|
82
|
+
for (const choice of field.choices) {
|
|
83
|
+
const opt = document.createElement("option");
|
|
84
|
+
opt.value = choice;
|
|
85
|
+
opt.textContent = choice;
|
|
86
|
+
if (String(values[field.name]) === choice) opt.selected = true;
|
|
87
|
+
input.appendChild(opt);
|
|
88
|
+
}
|
|
89
|
+
input.addEventListener("change", () => {
|
|
90
|
+
values[field.name] = input.value;
|
|
91
|
+
updatePreview();
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
input = document.createElement("input");
|
|
95
|
+
input.type = field.type === "number" ? "number" : "text";
|
|
96
|
+
input.value = values[field.name] === undefined ? "" : String(values[field.name]);
|
|
97
|
+
input.placeholder = field.type === "directory" || field.type === "file" ? "Path" : "";
|
|
98
|
+
input.addEventListener("input", () => {
|
|
99
|
+
values[field.name] = field.type === "number" && input.value !== "" ? Number(input.value) : input.value;
|
|
100
|
+
updatePreview();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
input.id = "field-" + field.name;
|
|
104
|
+
wrap.appendChild(input);
|
|
105
|
+
return wrap;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendOutput(stream, text) {
|
|
109
|
+
const span = document.createElement("span");
|
|
110
|
+
span.className = stream;
|
|
111
|
+
span.textContent = text;
|
|
112
|
+
outputEl.appendChild(span);
|
|
113
|
+
outputEl.scrollTop = outputEl.scrollHeight;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function killTree(pid) {
|
|
117
|
+
if (payload.platform === "win32") {
|
|
118
|
+
spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch {} }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function finish(code) {
|
|
125
|
+
const ms = Date.now() - startedAt;
|
|
126
|
+
child = null;
|
|
127
|
+
runBtn.disabled = false;
|
|
128
|
+
cancelBtn.disabled = true;
|
|
129
|
+
const phase = code === 0 ? "done" : "failed";
|
|
130
|
+
setStatus(phase, code === 0 ? "Done" : "Failed");
|
|
131
|
+
resultEl.textContent = "Exit code " + code + " · " + (ms / 1000).toFixed(1) + "s";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function run() {
|
|
135
|
+
const missing = missingRequired();
|
|
136
|
+
if (missing.length) {
|
|
137
|
+
setStatus("failed", "Missing required fields");
|
|
138
|
+
resultEl.textContent = "Fill in: " + missing.join(", ");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const argv = buildArgv();
|
|
142
|
+
outputEl.replaceChildren();
|
|
143
|
+
resultEl.textContent = "";
|
|
144
|
+
setStatus("running", "Running");
|
|
145
|
+
runBtn.disabled = true;
|
|
146
|
+
cancelBtn.disabled = false;
|
|
147
|
+
startedAt = Date.now();
|
|
148
|
+
try {
|
|
149
|
+
writeFileSync(payload.lastRunPath, JSON.stringify(values, null, 2) + "\\n");
|
|
150
|
+
} catch {}
|
|
151
|
+
|
|
152
|
+
child = spawn(process.execPath, [payload.spec.target, ...argv], {
|
|
153
|
+
cwd: payload.spec.cwd,
|
|
154
|
+
env: process.env,
|
|
155
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
156
|
+
detached: payload.platform !== "win32",
|
|
157
|
+
});
|
|
158
|
+
child.stdout.on("data", (chunk) => {
|
|
159
|
+
setStatus("streaming", "Streaming output");
|
|
160
|
+
appendOutput("stdout", chunk.toString("utf8"));
|
|
161
|
+
});
|
|
162
|
+
child.stderr.on("data", (chunk) => {
|
|
163
|
+
setStatus("streaming", "Streaming output");
|
|
164
|
+
appendOutput("stderr", chunk.toString("utf8"));
|
|
165
|
+
});
|
|
166
|
+
child.on("error", (error) => {
|
|
167
|
+
appendOutput("stderr", error.message + "\\n");
|
|
168
|
+
finish(1);
|
|
169
|
+
});
|
|
170
|
+
child.on("close", (code) => finish(code ?? 1));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function cancel() {
|
|
174
|
+
if (!child || child.pid == null) return;
|
|
175
|
+
killTree(child.pid);
|
|
176
|
+
appendOutput("stderr", "\\nCancelled.\\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const field of payload.spec.fields) {
|
|
180
|
+
formEl.appendChild(fieldControl(field));
|
|
181
|
+
}
|
|
182
|
+
updatePreview();
|
|
183
|
+
setStatus("ready", "Ready — review the command, then Run");
|
|
184
|
+
runBtn.addEventListener("click", run);
|
|
185
|
+
cancelBtn.addEventListener("click", cancel);
|
|
186
|
+
document.addEventListener("keydown", (event) => {
|
|
187
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) run();
|
|
188
|
+
});
|
|
189
|
+
`;
|
|
190
|
+
export const windowStyles = `:root {
|
|
191
|
+
color-scheme: light;
|
|
192
|
+
--bg: #f6f4ef;
|
|
193
|
+
--surface: #fffdf8;
|
|
194
|
+
--ink: #1f1b16;
|
|
195
|
+
--muted: #5c564d;
|
|
196
|
+
--line: #d9d2c5;
|
|
197
|
+
--accent: #2f5d50;
|
|
198
|
+
--danger: #8a2f2f;
|
|
199
|
+
--ok: #2f5d50;
|
|
200
|
+
}
|
|
201
|
+
* { box-sizing: border-box; }
|
|
202
|
+
html, body { margin: 0; height: 100%; overflow: hidden; }
|
|
203
|
+
body {
|
|
204
|
+
font: 14px/1.4 "Segoe UI", system-ui, sans-serif;
|
|
205
|
+
background: var(--bg);
|
|
206
|
+
color: var(--ink);
|
|
207
|
+
display: flex;
|
|
208
|
+
flex-direction: column;
|
|
209
|
+
}
|
|
210
|
+
header {
|
|
211
|
+
display: flex;
|
|
212
|
+
justify-content: space-between;
|
|
213
|
+
gap: 16px;
|
|
214
|
+
align-items: baseline;
|
|
215
|
+
padding: 10px 16px;
|
|
216
|
+
border-bottom: 1px solid var(--line);
|
|
217
|
+
background: var(--surface);
|
|
218
|
+
flex-shrink: 0;
|
|
219
|
+
}
|
|
220
|
+
h1 { margin: 0; font-size: 1.1rem; }
|
|
221
|
+
h2 { margin: 0 0 6px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
|
222
|
+
.desc, .muted { color: var(--muted); margin: 2px 0 0; }
|
|
223
|
+
#status { margin: 0; white-space: nowrap; }
|
|
224
|
+
main {
|
|
225
|
+
flex: 1;
|
|
226
|
+
min-height: 0;
|
|
227
|
+
display: grid;
|
|
228
|
+
grid-template-columns: minmax(240px, 1fr) minmax(320px, 1.2fr);
|
|
229
|
+
gap: 12px;
|
|
230
|
+
padding: 12px 16px 16px;
|
|
231
|
+
}
|
|
232
|
+
.form-pane, .run-pane {
|
|
233
|
+
min-height: 0;
|
|
234
|
+
min-width: 0;
|
|
235
|
+
overflow: auto;
|
|
236
|
+
}
|
|
237
|
+
.run-pane {
|
|
238
|
+
display: flex;
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
gap: 8px;
|
|
241
|
+
}
|
|
242
|
+
.field { display: flex; flex-direction: column; gap: 2px; margin-bottom: 10px; }
|
|
243
|
+
.field-label { font-weight: 600; }
|
|
244
|
+
.field-help { color: var(--muted); font-size: 0.8rem; }
|
|
245
|
+
input, select {
|
|
246
|
+
font: inherit;
|
|
247
|
+
padding: 6px 8px;
|
|
248
|
+
border: 1px solid var(--line);
|
|
249
|
+
border-radius: 6px;
|
|
250
|
+
background: #fff;
|
|
251
|
+
}
|
|
252
|
+
input[type="checkbox"] { width: 1.1rem; height: 1.1rem; }
|
|
253
|
+
.preview, .output {
|
|
254
|
+
background: #1f1b16;
|
|
255
|
+
color: #f6f4ef;
|
|
256
|
+
border-radius: 8px;
|
|
257
|
+
padding: 8px 10px;
|
|
258
|
+
overflow: auto;
|
|
259
|
+
white-space: pre-wrap;
|
|
260
|
+
font: 12px/1.4 ui-monospace, Consolas, monospace;
|
|
261
|
+
}
|
|
262
|
+
.preview { max-height: 4.6em; flex-shrink: 0; }
|
|
263
|
+
.output { flex: 1; min-height: 120px; }
|
|
264
|
+
.output .stderr { color: #f0b4b4; }
|
|
265
|
+
.actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
|
266
|
+
@media (max-width: 720px) {
|
|
267
|
+
main { grid-template-columns: 1fr; }
|
|
268
|
+
}
|
|
269
|
+
button {
|
|
270
|
+
font: inherit;
|
|
271
|
+
border: 0;
|
|
272
|
+
border-radius: 6px;
|
|
273
|
+
padding: 8px 14px;
|
|
274
|
+
cursor: pointer;
|
|
275
|
+
}
|
|
276
|
+
#run { background: var(--accent); color: #fff; }
|
|
277
|
+
#cancel { background: #ece7dc; color: var(--ink); }
|
|
278
|
+
#cancel:disabled, #run:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
279
|
+
#status[data-phase="running"], #status[data-phase="streaming"] { color: var(--accent); }
|
|
280
|
+
#status[data-phase="done"] { color: var(--ok); }
|
|
281
|
+
#status[data-phase="failed"] { color: var(--danger); }
|
|
282
|
+
`;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FormSpec, FormValues } from "../schema/form.js";
|
|
2
|
+
export type WindowPayload = {
|
|
3
|
+
spec: FormSpec;
|
|
4
|
+
values: FormValues;
|
|
5
|
+
nodePath: string;
|
|
6
|
+
lastRunPath: string;
|
|
7
|
+
platform: NodeJS.Platform;
|
|
8
|
+
};
|
|
9
|
+
export declare function writeWindowApp(payload: WindowPayload, dest?: string): Promise<string>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { windowAppJs, windowStyles } from "./window-app.js";
|
|
5
|
+
export async function writeWindowApp(payload, dest) {
|
|
6
|
+
const dir = dest ?? join(tmpdir(), `gui4cli-window-${Date.now()}`);
|
|
7
|
+
await mkdir(dir, { recursive: true });
|
|
8
|
+
const html = `<!DOCTYPE html>
|
|
9
|
+
<html lang="en">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="utf-8" />
|
|
12
|
+
<title>${escapeHtml(payload.spec.title)}</title>
|
|
13
|
+
<style>${windowStyles}</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<header>
|
|
17
|
+
<div>
|
|
18
|
+
<h1>${escapeHtml(payload.spec.title)}</h1>
|
|
19
|
+
<p class="desc">${escapeHtml(payload.spec.description ?? "Fill the form, review the command, then Run.")}</p>
|
|
20
|
+
</div>
|
|
21
|
+
<p id="status" class="muted" data-phase="ready">Ready</p>
|
|
22
|
+
</header>
|
|
23
|
+
<main>
|
|
24
|
+
<form id="form" class="form-pane"></form>
|
|
25
|
+
<section class="run-pane">
|
|
26
|
+
<h2>Command preview</h2>
|
|
27
|
+
<pre id="preview" class="preview"></pre>
|
|
28
|
+
<div class="actions">
|
|
29
|
+
<button type="button" id="run">Run</button>
|
|
30
|
+
<button type="button" id="cancel" disabled>Cancel</button>
|
|
31
|
+
<span id="result" class="muted"></span>
|
|
32
|
+
</div>
|
|
33
|
+
<h2>Output</h2>
|
|
34
|
+
<pre id="output" class="output"></pre>
|
|
35
|
+
</section>
|
|
36
|
+
</main>
|
|
37
|
+
<script type="module" src="./app.js"></script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
40
|
+
`;
|
|
41
|
+
await writeFile(join(dir, "index.html"), html, "utf8");
|
|
42
|
+
await writeFile(join(dir, "app.js"), windowAppJs, "utf8");
|
|
43
|
+
await writeFile(join(dir, "payload.js"), `export const payload = ${JSON.stringify(payload, null, 2)};\n`, "utf8");
|
|
44
|
+
await writeFile(join(dir, "vite.config.js"), `export default {
|
|
45
|
+
server: { hmr: false, watch: null },
|
|
46
|
+
};
|
|
47
|
+
`, "utf8");
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
function escapeHtml(value) {
|
|
51
|
+
return value
|
|
52
|
+
.replaceAll("&", "&")
|
|
53
|
+
.replaceAll("<", "<")
|
|
54
|
+
.replaceAll(">", ">")
|
|
55
|
+
.replaceAll('"', """);
|
|
56
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type FormValues } from "../schema/form.js";
|
|
2
|
+
export declare function lastRunPath(target: string): string;
|
|
3
|
+
export declare function loadLastRun(target: string): Promise<FormValues | null>;
|
|
4
|
+
export declare function saveLastRun(target: string, values: FormValues): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { formValuesSchema } from "../schema/form.js";
|
|
6
|
+
export function lastRunPath(target) {
|
|
7
|
+
const hash = createHash("sha1").update(target).digest("hex").slice(0, 16);
|
|
8
|
+
return join(homedir(), ".gui4cli", "lastrun", `${hash}.json`);
|
|
9
|
+
}
|
|
10
|
+
export async function loadLastRun(target) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = JSON.parse(await readFile(lastRunPath(target), "utf8"));
|
|
13
|
+
return formValuesSchema.parse(raw);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function saveLastRun(target, values) {
|
|
20
|
+
const path = lastRunPath(target);
|
|
21
|
+
await mkdir(dirname(path), { recursive: true });
|
|
22
|
+
await writeFile(path, `${JSON.stringify(values, null, 2)}\n`, "utf8");
|
|
23
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { Gui4CliError } from "../errors.js";
|
|
5
|
+
export async function resolveTarget(input, entry, cwd) {
|
|
6
|
+
if (entry) {
|
|
7
|
+
const target = resolvePath(entry, cwd);
|
|
8
|
+
assertFile(target, `Could not find the entry file at ${entry}.`);
|
|
9
|
+
return { target, cwd: dirname(target) };
|
|
10
|
+
}
|
|
11
|
+
if (!input) {
|
|
12
|
+
throw new Gui4CliError("Pass a script file or a project folder.", "Example: gui4cli fixtures/resize.js");
|
|
13
|
+
}
|
|
14
|
+
const path = resolvePath(input, cwd);
|
|
15
|
+
if (!existsSync(path)) {
|
|
16
|
+
throw new Gui4CliError(`Could not find ${input}.`, "Check the path, or pass --entry if the script lives somewhere else.");
|
|
17
|
+
}
|
|
18
|
+
const info = await stat(path);
|
|
19
|
+
if (info.isFile()) {
|
|
20
|
+
return { target: path, cwd: dirname(path) };
|
|
21
|
+
}
|
|
22
|
+
if (info.isDirectory()) {
|
|
23
|
+
const fromPkg = await entryFromPackage(path);
|
|
24
|
+
return { target: fromPkg, cwd: path };
|
|
25
|
+
}
|
|
26
|
+
throw new Gui4CliError(`Could not use ${input} as a script or folder.`);
|
|
27
|
+
}
|
|
28
|
+
function resolvePath(input, cwd) {
|
|
29
|
+
return isAbsolute(input) ? input : resolve(cwd, input);
|
|
30
|
+
}
|
|
31
|
+
function assertFile(path, message) {
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
throw new Gui4CliError(message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function entryFromPackage(dir) {
|
|
37
|
+
const pkgPath = resolve(dir, "package.json");
|
|
38
|
+
if (!existsSync(pkgPath)) {
|
|
39
|
+
throw new Gui4CliError(`No package.json in ${dir}.`, "Pass a script file, or use --entry path/to/cli.js.");
|
|
40
|
+
}
|
|
41
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
42
|
+
const bin = typeof pkg.bin === "string"
|
|
43
|
+
? pkg.bin
|
|
44
|
+
: pkg.bin
|
|
45
|
+
? Object.values(pkg.bin)[0]
|
|
46
|
+
: undefined;
|
|
47
|
+
const rel = bin ?? pkg.main;
|
|
48
|
+
if (!rel) {
|
|
49
|
+
throw new Gui4CliError(`package.json in ${dir} has no bin or main field.`, "Add one, or run gui4cli --entry path/to/cli.js.");
|
|
50
|
+
}
|
|
51
|
+
const target = resolve(dir, rel);
|
|
52
|
+
assertFile(target, `package.json points at ${rel}, but that file is missing.`);
|
|
53
|
+
return target;
|
|
54
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Field, FormValues } from "../schema/form.js";
|
|
2
|
+
export declare function valuesFromDefaults(fields: Field[]): FormValues;
|
|
3
|
+
export declare function buildArgv(fields: Field[], values: FormValues): string[];
|
|
4
|
+
export declare function previewCommand(nodePath: string, target: string, argv: string[], cwd: string): string;
|
|
5
|
+
export declare function missingRequired(fields: Field[], values: FormValues): string[];
|