@haltcase/run 3.0.1 → 5.0.0-beta.1
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/dist/cli/bin.d.mts +1 -0
- package/dist/cli/bin.mjs +7 -0
- package/dist/cli/handler.mjs +110 -0
- package/dist/cli/main.mjs +56 -0
- package/dist/cli/parseOptions.d.mts +7 -0
- package/dist/cli/parseOptions.mjs +36 -0
- package/dist/cli/spinner.mjs +128 -0
- package/dist/config.d.mts +18 -0
- package/dist/config.mjs +19 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +2 -0
- package/dist/tasks/executeTask.mjs +83 -0
- package/dist/tasks/guards.mjs +4 -0
- package/dist/tasks/task.d.mts +10 -0
- package/dist/tasks/task.mjs +40 -0
- package/dist/tasks/types.d.mts +60 -0
- package/dist/util/getSchemaProperties.mjs +34 -0
- package/dist/util/loadTaskFile.mjs +31 -0
- package/dist/util/resolveTaskFile.mjs +13 -0
- package/package.json +51 -42
- package/dist/cli/bin.d.ts +0 -2
- package/dist/cli/bin.js +0 -7
- package/dist/cli/handler.d.ts +0 -28
- package/dist/cli/handler.js +0 -105
- package/dist/cli/main.d.ts +0 -21
- package/dist/cli/main.js +0 -64
- package/dist/cli/parseOptions.d.ts +0 -13
- package/dist/cli/parseOptions.js +0 -48
- package/dist/config.d.ts +0 -16
- package/dist/config.js +0 -16
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -1
- package/dist/tasks/executeTask.d.ts +0 -12
- package/dist/tasks/executeTask.js +0 -45
- package/dist/tasks/guards.d.ts +0 -3
- package/dist/tasks/guards.js +0 -2
- package/dist/tasks/task.d.ts +0 -14
- package/dist/tasks/task.js +0 -55
- package/dist/tasks/types.d.ts +0 -58
- package/dist/tasks/types.js +0 -1
- package/dist/tsconfig.build.tsbuildinfo +0 -1
- package/dist/util/getSchemaProperties.d.ts +0 -2
- package/dist/util/getSchemaProperties.js +0 -40
- package/dist/util/loadTaskFile.d.ts +0 -8
- package/dist/util/loadTaskFile.js +0 -30
- package/dist/util/resolveTaskFile.d.ts +0 -3
- package/dist/util/resolveTaskFile.js +0 -17
- package/dist/util/result.d.ts +0 -9
- package/dist/util/result.js +0 -1
- /package/{license → LICENSE} +0 -0
- /package/{readme.md → README.md} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli/bin.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isBrandedTask } from "../tasks/guards.mjs";
|
|
2
|
+
import { getSchemaProperties } from "../util/getSchemaProperties.mjs";
|
|
3
|
+
import { extensions } from "../util/resolveTaskFile.mjs";
|
|
4
|
+
import { Spinner } from "./spinner.mjs";
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { readdirSync } from "node:fs";
|
|
7
|
+
import { extname } from "node:path";
|
|
8
|
+
import cliui from "cliui";
|
|
9
|
+
//#region src/cli/handler.ts
|
|
10
|
+
const failWith = (spinner, message) => {
|
|
11
|
+
let text;
|
|
12
|
+
if (Array.isArray(message)) text = message.map(String).join("\n");
|
|
13
|
+
if (text == null || typeof text !== "string") text = String(message);
|
|
14
|
+
spinner.error({ text });
|
|
15
|
+
process.exit(1);
|
|
16
|
+
};
|
|
17
|
+
const write = (message) => {
|
|
18
|
+
process.stdout.write(`${message}\n`);
|
|
19
|
+
};
|
|
20
|
+
const usage = `Usage: ${styleText("yellow", "hr")} <taskFile> [task]`;
|
|
21
|
+
const commandHandler = (_context) => {
|
|
22
|
+
return usage;
|
|
23
|
+
};
|
|
24
|
+
const taskFileListHandler = (context) => {
|
|
25
|
+
return `${[
|
|
26
|
+
usage,
|
|
27
|
+
"Available task files:",
|
|
28
|
+
readdirSync(context.config.taskDirectory).filter((file) => extensions.includes(extname(file))).map((it) => ` ${it}`).join("\n")
|
|
29
|
+
].join("\n\n")}\n`;
|
|
30
|
+
};
|
|
31
|
+
const taskListHandler = (context) => {
|
|
32
|
+
const ui = cliui({
|
|
33
|
+
width: Math.max(0, process.stdout.columns - 4),
|
|
34
|
+
wrap: true
|
|
35
|
+
});
|
|
36
|
+
const { config } = context.taskFile.data;
|
|
37
|
+
const baseUsage = usage.replace("<taskFile>", context.taskFile.name);
|
|
38
|
+
ui.div(`${baseUsage} <parameter> [...options]`);
|
|
39
|
+
ui.div({
|
|
40
|
+
text: "Available tasks:",
|
|
41
|
+
padding: [
|
|
42
|
+
1,
|
|
43
|
+
0,
|
|
44
|
+
1,
|
|
45
|
+
0
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
const taskListPaddingX = 2;
|
|
49
|
+
const nameColumnWidth = Object.keys(config).reduce((previous, current) => Math.max(previous, current.length), 1) + 2 + taskListPaddingX * 2;
|
|
50
|
+
for (const [name, value] of Object.entries(config)) {
|
|
51
|
+
if (!value) continue;
|
|
52
|
+
const formattedName = styleText("bold", name);
|
|
53
|
+
if (isBrandedTask(value)) {
|
|
54
|
+
if (value.kind === "strictTask") {
|
|
55
|
+
const properties = getSchemaProperties(value.schema) || styleText("gray", "not available");
|
|
56
|
+
ui.div({
|
|
57
|
+
text: formattedName,
|
|
58
|
+
width: nameColumnWidth,
|
|
59
|
+
padding: [
|
|
60
|
+
0,
|
|
61
|
+
taskListPaddingX,
|
|
62
|
+
0,
|
|
63
|
+
taskListPaddingX
|
|
64
|
+
]
|
|
65
|
+
}, properties);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
ui.div({
|
|
69
|
+
text: formattedName,
|
|
70
|
+
width: nameColumnWidth,
|
|
71
|
+
padding: [
|
|
72
|
+
0,
|
|
73
|
+
taskListPaddingX,
|
|
74
|
+
0,
|
|
75
|
+
taskListPaddingX
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
ui.div({
|
|
81
|
+
text: formattedName,
|
|
82
|
+
width: nameColumnWidth,
|
|
83
|
+
padding: [
|
|
84
|
+
0,
|
|
85
|
+
taskListPaddingX,
|
|
86
|
+
0,
|
|
87
|
+
taskListPaddingX
|
|
88
|
+
]
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
ui.div("");
|
|
92
|
+
return ui.toString();
|
|
93
|
+
};
|
|
94
|
+
const help = (context) => {
|
|
95
|
+
if ("command" in context) return commandHandler(context);
|
|
96
|
+
if ("taskFileList" in context) return taskFileListHandler(context);
|
|
97
|
+
if ("taskList" in context) return taskListHandler(context);
|
|
98
|
+
return "";
|
|
99
|
+
};
|
|
100
|
+
const createHandler = () => {
|
|
101
|
+
const spinner = new Spinner();
|
|
102
|
+
return {
|
|
103
|
+
spinner,
|
|
104
|
+
help,
|
|
105
|
+
write,
|
|
106
|
+
failWith: (message) => failWith(spinner, message)
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
export { createHandler };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { parseOptions } from "./parseOptions.mjs";
|
|
2
|
+
import { resolveTaskFile } from "../util/resolveTaskFile.mjs";
|
|
3
|
+
import { getAppConfig } from "../config.mjs";
|
|
4
|
+
import { executeTask } from "../tasks/executeTask.mjs";
|
|
5
|
+
import { loadTaskFile } from "../util/loadTaskFile.mjs";
|
|
6
|
+
import { parse, resolve } from "node:path";
|
|
7
|
+
//#region src/cli/main.ts
|
|
8
|
+
const main = async (props) => {
|
|
9
|
+
const { config } = await getAppConfig();
|
|
10
|
+
const inputFileName = process.argv[2];
|
|
11
|
+
const taskName = process.argv[3] ?? "";
|
|
12
|
+
if (!inputFileName) {
|
|
13
|
+
props.handler.write(props.handler.help({
|
|
14
|
+
taskFileList: true,
|
|
15
|
+
config
|
|
16
|
+
}));
|
|
17
|
+
props.handler.failWith("Task file name is required");
|
|
18
|
+
}
|
|
19
|
+
const fullFilePath = resolve(config.taskDirectory, inputFileName);
|
|
20
|
+
const context = {
|
|
21
|
+
...props,
|
|
22
|
+
config,
|
|
23
|
+
taskFile: {
|
|
24
|
+
...parse(fullFilePath),
|
|
25
|
+
path: fullFilePath
|
|
26
|
+
},
|
|
27
|
+
taskName
|
|
28
|
+
};
|
|
29
|
+
const resolutions = resolveTaskFile(context);
|
|
30
|
+
if (Array.isArray(resolutions)) props.handler.failWith([
|
|
31
|
+
`Found multiple task files with the name '${context.taskFile.name}'`,
|
|
32
|
+
`Rename the ambiguous files or specify an extension and try again`,
|
|
33
|
+
resolutions.map((it) => ` ${it}`).join("\n")
|
|
34
|
+
]);
|
|
35
|
+
context.taskFile = {
|
|
36
|
+
...parse(resolutions),
|
|
37
|
+
path: resolutions
|
|
38
|
+
};
|
|
39
|
+
const taskFileResult = await loadTaskFile(context);
|
|
40
|
+
if (!taskFileResult.ok) props.handler.failWith(taskFileResult.error);
|
|
41
|
+
const contextWithData = {
|
|
42
|
+
...context,
|
|
43
|
+
taskFile: {
|
|
44
|
+
...context.taskFile,
|
|
45
|
+
data: taskFileResult.value
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
await executeTask(contextWithData, parseOptions(process.argv.slice(4)));
|
|
50
|
+
if (!config.quiet) props.handler.spinner.success({ text: "Success" });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
props.handler.failWith([`Failed to execute task`, String(error)]);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { main };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
//#region src/cli/parseOptions.ts
|
|
3
|
+
const reservedNames = new Set(["_", "env"]);
|
|
4
|
+
/**
|
|
5
|
+
* Wrapper around Node's {@link parseArgs} that treats options as strings
|
|
6
|
+
* by default, instead of boolean flags.
|
|
7
|
+
*
|
|
8
|
+
* @param args - argv-like string
|
|
9
|
+
* @returns
|
|
10
|
+
*/
|
|
11
|
+
const parseOptions = (args) => {
|
|
12
|
+
const { tokens } = parseArgs({
|
|
13
|
+
args,
|
|
14
|
+
allowPositionals: true,
|
|
15
|
+
strict: false,
|
|
16
|
+
tokens: true
|
|
17
|
+
});
|
|
18
|
+
const options = { _: [] };
|
|
19
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
20
|
+
const token = tokens[index];
|
|
21
|
+
if (token.kind === "option-terminator") continue;
|
|
22
|
+
if (token.kind === "option") {
|
|
23
|
+
if (reservedNames.has(token.name)) throw new Error(`Reserved name '${token.name}' cannot be used as option`);
|
|
24
|
+
if (index + 1 < tokens.length) {
|
|
25
|
+
const next = tokens[index + 1];
|
|
26
|
+
if (next.kind !== "positional") throw new Error(`Expected value for option ${token.rawName}`);
|
|
27
|
+
options[token.name] = next.value;
|
|
28
|
+
index++;
|
|
29
|
+
} else throw new Error(`Expected option ${token.rawName} to be followed by a value`);
|
|
30
|
+
}
|
|
31
|
+
if (token.kind === "positional") options._.push(token.value);
|
|
32
|
+
}
|
|
33
|
+
return options;
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { parseOptions, reservedNames };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
import tty from "node:tty";
|
|
3
|
+
//#region src/cli/spinner.ts
|
|
4
|
+
/**
|
|
5
|
+
* Based on `nanospinner` and `@favware/colorette-spinner` but using
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/usmanyunusov/nanospinner (ISC © 2021 Usman Yunusov)
|
|
8
|
+
* @see https://github.com/favware/colorette-spinner (MIT © 2022 Favware)
|
|
9
|
+
*/
|
|
10
|
+
var Spinner = class {
|
|
11
|
+
#isCI = process.env.CI || process.env.WT_SESSION || process.env.ConEmuTask === "{cmd::Cmder}" || process.env.TERM_PROGRAM === "vscode" || process.env.TERM === "xterm-256color" || process.env.TERM === "alacritty";
|
|
12
|
+
#isTTY = tty.isatty(1) && process.env.TERM !== "dumb" && !("CI" in process.env);
|
|
13
|
+
#supportUnicode = process.platform === "win32" ? this.#isCI : process.env.TERM !== "linux";
|
|
14
|
+
#symbols = {
|
|
15
|
+
frames: this.#isTTY ? this.#supportUnicode ? [
|
|
16
|
+
"⠋",
|
|
17
|
+
"⠙",
|
|
18
|
+
"⠹",
|
|
19
|
+
"⠸",
|
|
20
|
+
"⠼",
|
|
21
|
+
"⠴",
|
|
22
|
+
"⠦",
|
|
23
|
+
"⠧",
|
|
24
|
+
"⠇",
|
|
25
|
+
"⠏"
|
|
26
|
+
] : [
|
|
27
|
+
"-",
|
|
28
|
+
"\\",
|
|
29
|
+
"|",
|
|
30
|
+
"/"
|
|
31
|
+
] : ["-"],
|
|
32
|
+
tick: this.#supportUnicode ? "✔" : "√",
|
|
33
|
+
cross: this.#supportUnicode ? "✖" : "×"
|
|
34
|
+
};
|
|
35
|
+
#text = "";
|
|
36
|
+
#current = 0;
|
|
37
|
+
#interval = 50;
|
|
38
|
+
#stream = process.stderr;
|
|
39
|
+
#frames = this.#symbols.frames;
|
|
40
|
+
#color = "greenBright";
|
|
41
|
+
#lines = 0;
|
|
42
|
+
#timer;
|
|
43
|
+
constructor(text, options) {
|
|
44
|
+
this.#text = text ?? this.#text;
|
|
45
|
+
this.#interval = options?.interval ?? this.#interval;
|
|
46
|
+
this.#stream = options?.stream ?? this.#stream;
|
|
47
|
+
if (options?.frames?.length) this.#frames = options.frames;
|
|
48
|
+
this.#color = options?.color ?? this.#color;
|
|
49
|
+
}
|
|
50
|
+
clear() {
|
|
51
|
+
this.write("\x1B[1G");
|
|
52
|
+
for (let index = 0; index < this.#lines; index++) {
|
|
53
|
+
if (index > 0) this.write("\x1B[1A");
|
|
54
|
+
this.write("\x1B[2K\x1B[1G");
|
|
55
|
+
}
|
|
56
|
+
this.#lines = 0;
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
error(...[options = {}]) {
|
|
60
|
+
const mark = styleText("red", this.#symbols.cross);
|
|
61
|
+
return this.stop({
|
|
62
|
+
mark,
|
|
63
|
+
...options
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
reset() {
|
|
67
|
+
this.#current = 0;
|
|
68
|
+
this.#lines = 0;
|
|
69
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
spin() {
|
|
73
|
+
this.render();
|
|
74
|
+
this.#current = ++this.#current % this.#frames.length;
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
start(...[options = {}]) {
|
|
78
|
+
if (this.#timer) this.reset();
|
|
79
|
+
return this.update({
|
|
80
|
+
text: options.text ?? this.#text,
|
|
81
|
+
color: options.color ?? this.#color
|
|
82
|
+
}).loop();
|
|
83
|
+
}
|
|
84
|
+
stop(...[options = {}]) {
|
|
85
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
86
|
+
const mark = styleText(options.color || this.#color, this.#frames[this.#current] || "");
|
|
87
|
+
const optionsMark = options.mark && options.color ? styleText(options.color, options.mark) : options.mark;
|
|
88
|
+
this.write(`${optionsMark || mark} ${options.text || this.#text}\n`, true);
|
|
89
|
+
return this.#isTTY ? this.write(`\u001B[?25h`) : this;
|
|
90
|
+
}
|
|
91
|
+
success(...[options = {}]) {
|
|
92
|
+
const mark = styleText("green", this.#symbols.tick);
|
|
93
|
+
return this.stop({
|
|
94
|
+
mark,
|
|
95
|
+
...options
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
update(...[options = {}]) {
|
|
99
|
+
this.#text = options.text || this.#text;
|
|
100
|
+
this.#interval = options.interval ?? this.#interval;
|
|
101
|
+
this.#stream = options.stream ?? this.#stream;
|
|
102
|
+
if (options.frames?.length) this.#frames = options.frames;
|
|
103
|
+
this.#color = options.color ?? this.#color;
|
|
104
|
+
if (this.#frames.length - 1 < this.#current) this.#current = 0;
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
loop() {
|
|
108
|
+
if (this.#isTTY) this.#timer = setTimeout(() => this.loop(), this.#interval);
|
|
109
|
+
return this.spin();
|
|
110
|
+
}
|
|
111
|
+
write(value, clear = false) {
|
|
112
|
+
if (clear && this.#isTTY) this.clear();
|
|
113
|
+
this.#stream.write(value);
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
render() {
|
|
117
|
+
let value = `${styleText(this.#color, this.#frames[this.#current] || "")} ${this.#text}`;
|
|
118
|
+
if (this.#isTTY) this.write(`\u001B[?25l`);
|
|
119
|
+
else value += "\n";
|
|
120
|
+
this.write(value, true);
|
|
121
|
+
if (this.#isTTY) this.#lines = this.getLines(value, this.#stream.columns);
|
|
122
|
+
}
|
|
123
|
+
getLines(value = "", width = 80) {
|
|
124
|
+
return value.replaceAll(/\u001B[^m]*m/g, "").split("\n").reduce((column, line) => column + Math.max(1, Math.ceil(line.length / width)), 0);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
128
|
+
export { Spinner };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
interface AppConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Directory containing task files. Defaults to the `scripts` folder within
|
|
5
|
+
* the current working directory.
|
|
6
|
+
*
|
|
7
|
+
* @default `<cwd>/scripts`
|
|
8
|
+
*/
|
|
9
|
+
taskDirectory: string;
|
|
10
|
+
/**
|
|
11
|
+
* Print no output unless errors occur.
|
|
12
|
+
*
|
|
13
|
+
* @default false
|
|
14
|
+
*/
|
|
15
|
+
quiet: boolean;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { AppConfig };
|
package/dist/config.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadConfig } from "c12";
|
|
3
|
+
import { cwd } from "node:process";
|
|
4
|
+
//#region src/config.ts
|
|
5
|
+
const getAppConfig = async () => {
|
|
6
|
+
const configName = "haltcase.run";
|
|
7
|
+
return loadConfig({
|
|
8
|
+
name: configName,
|
|
9
|
+
configFile: configName,
|
|
10
|
+
packageJson: configName,
|
|
11
|
+
rcFile: configName,
|
|
12
|
+
defaults: {
|
|
13
|
+
taskDirectory: join(cwd(), "scripts"),
|
|
14
|
+
quiet: false
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { getAppConfig };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ParsedOptions } from "./cli/parseOptions.mjs";
|
|
2
|
+
import { AppConfig } from "./config.mjs";
|
|
3
|
+
import { Task } from "./tasks/types.mjs";
|
|
4
|
+
import { task } from "./tasks/task.mjs";
|
|
5
|
+
export { type AppConfig as HaltcaseRunConfig, type ParsedOptions, type Task, task };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { $, execa } from "execa";
|
|
2
|
+
const taskUtilities = {
|
|
3
|
+
command: execa,
|
|
4
|
+
$,
|
|
5
|
+
exec: execa({
|
|
6
|
+
stdin: "inherit",
|
|
7
|
+
stdout: "inherit",
|
|
8
|
+
stderr: "inherit"
|
|
9
|
+
})
|
|
10
|
+
};
|
|
11
|
+
const executeTask = async (context, options) => {
|
|
12
|
+
const taskFunction = context.taskFile.data.config[context.taskName];
|
|
13
|
+
const specifier = `${context.taskFile.name}::${context.taskName}`;
|
|
14
|
+
if (taskFunction == null) {
|
|
15
|
+
context.handler.write(context.handler.help({
|
|
16
|
+
taskList: true,
|
|
17
|
+
...context
|
|
18
|
+
}));
|
|
19
|
+
if (!context.taskName) context.handler.failWith(`Task name is required.`);
|
|
20
|
+
context.handler.failWith([`Task file '${context.taskFile.name}' does not export '${context.taskName}'`, `Resolved to: ${context.taskFile.path}`]);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const optionsWithEnvironment = {
|
|
24
|
+
...options,
|
|
25
|
+
env: process.env
|
|
26
|
+
};
|
|
27
|
+
const isSpinnerEnabled = !context.config.quiet;
|
|
28
|
+
const pinnedSpinnerRenderInterval = 80;
|
|
29
|
+
if (isSpinnerEnabled) {
|
|
30
|
+
context.handler.spinner.start({ text: `Running ${specifier}` });
|
|
31
|
+
context.handler.spinner.update({ interval: pinnedSpinnerRenderInterval });
|
|
32
|
+
}
|
|
33
|
+
const originalStdoutWrite = Reflect.get(process.stdout, "write");
|
|
34
|
+
const originalStderrWrite = Reflect.get(process.stderr, "write");
|
|
35
|
+
const passthroughStdoutWrite = originalStdoutWrite.bind(process.stdout);
|
|
36
|
+
const passthroughStderrWrite = originalStderrWrite.bind(process.stderr);
|
|
37
|
+
let isSpinnerWrite = false;
|
|
38
|
+
let lastForcedRender = 0;
|
|
39
|
+
const clearPinnedSpinner = () => {
|
|
40
|
+
if (!isSpinnerEnabled) return;
|
|
41
|
+
isSpinnerWrite = true;
|
|
42
|
+
context.handler.spinner.clear();
|
|
43
|
+
isSpinnerWrite = false;
|
|
44
|
+
};
|
|
45
|
+
const renderPinnedSpinner = () => {
|
|
46
|
+
if (!isSpinnerEnabled) return;
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (now - lastForcedRender < pinnedSpinnerRenderInterval) return;
|
|
49
|
+
lastForcedRender = now;
|
|
50
|
+
isSpinnerWrite = true;
|
|
51
|
+
context.handler.spinner.spin();
|
|
52
|
+
isSpinnerWrite = false;
|
|
53
|
+
};
|
|
54
|
+
const interceptWrite = (passthroughWrite) => {
|
|
55
|
+
return ((chunk, encoding, callback) => {
|
|
56
|
+
const writeArgs = [
|
|
57
|
+
chunk,
|
|
58
|
+
encoding,
|
|
59
|
+
callback
|
|
60
|
+
].filter((value) => value != null);
|
|
61
|
+
if (!isSpinnerEnabled || isSpinnerWrite) return Reflect.apply(passthroughWrite, process.stdout, writeArgs);
|
|
62
|
+
clearPinnedSpinner();
|
|
63
|
+
const didWrite = Reflect.apply(passthroughWrite, process.stdout, writeArgs);
|
|
64
|
+
renderPinnedSpinner();
|
|
65
|
+
return didWrite;
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
process.stdout.write = interceptWrite(passthroughStdoutWrite);
|
|
69
|
+
process.stderr.write = interceptWrite(passthroughStderrWrite);
|
|
70
|
+
try {
|
|
71
|
+
await taskFunction(optionsWithEnvironment, taskUtilities);
|
|
72
|
+
} finally {
|
|
73
|
+
process.stdout.write = originalStdoutWrite;
|
|
74
|
+
process.stderr.write = originalStderrWrite;
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
if (message.includes("is not a function")) context.handler.failWith([`Failed to execute ${specifier}`, `Exported value '${context.taskName}' is not a function`]);
|
|
79
|
+
context.handler.failWith([`Failed to execute ${specifier}`, message]);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
export { executeTask };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BrandedTask, BrandedTaskStrict, DefaultOptionsInput, Task } from "./types.mjs";
|
|
2
|
+
import { Type, type } from "arktype";
|
|
3
|
+
|
|
4
|
+
//#region src/tasks/task.d.ts
|
|
5
|
+
declare const task: {
|
|
6
|
+
<T = DefaultOptionsInput>(fn: Task<T>): BrandedTask<T>;
|
|
7
|
+
strict<const TShape>(shape: type.validate<TShape>, fn: Task<NoInfer<Type<type.infer<TShape>>["infer"]>>): BrandedTaskStrict<Type<type.infer<TShape>>["inferIn"]>;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { task };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
import { type } from "arktype";
|
|
3
|
+
//#region src/tasks/task.ts
|
|
4
|
+
const formatValidationIssues = (errors) => errors.map((error) => {
|
|
5
|
+
const propertyName = String(error.path[0]);
|
|
6
|
+
const dottedPath = error.path.join(".");
|
|
7
|
+
if (!propertyName) return error.message;
|
|
8
|
+
const messageWithoutProperty = error.message.startsWith(dottedPath) ? error.message.slice(dottedPath.length + 1) : error.message;
|
|
9
|
+
if (propertyName === "env") return `${styleText("red", `Environment variable '${String(error.path[1])}'`)}: ${messageWithoutProperty}`;
|
|
10
|
+
const optionText = propertyName === "_" ? "Positionals" : `--${propertyName}`;
|
|
11
|
+
if (error.code === "predicate" && error.expected === "removed") return `${styleText("red", optionText)}: unknown option`;
|
|
12
|
+
return `${styleText("red", optionText)}: ${messageWithoutProperty}`;
|
|
13
|
+
}).join("\n");
|
|
14
|
+
const task = (fn) => {
|
|
15
|
+
fn.kind = "task";
|
|
16
|
+
return fn;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Default schema for the options received by a {@link task}.
|
|
20
|
+
*/
|
|
21
|
+
const defaultOptionsInput = type({
|
|
22
|
+
_: "string[]",
|
|
23
|
+
env: "Record<string, string | undefined>"
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Create a task that validates its input against a schema.
|
|
27
|
+
*/
|
|
28
|
+
task.strict = (shape, fn) => {
|
|
29
|
+
const schema = defaultOptionsInput.merge(type.raw(shape)).onUndeclaredKey("reject");
|
|
30
|
+
const taskFunction = (options, utilities) => {
|
|
31
|
+
const validationResult = schema(options);
|
|
32
|
+
if (validationResult instanceof type.errors) throw new TypeError(formatValidationIssues(validationResult), { cause: validationResult });
|
|
33
|
+
return fn(validationResult, utilities);
|
|
34
|
+
};
|
|
35
|
+
taskFunction.kind = "strictTask";
|
|
36
|
+
taskFunction.schema = schema;
|
|
37
|
+
return taskFunction;
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { task };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ParsedOptions } from "../cli/parseOptions.mjs";
|
|
2
|
+
import { Type } from "arktype";
|
|
3
|
+
import { ExecaMethod, ExecaScriptMethod } from "execa";
|
|
4
|
+
|
|
5
|
+
//#region src/tasks/types.d.ts
|
|
6
|
+
interface TaskUtilities {
|
|
7
|
+
/**
|
|
8
|
+
* Run a command using Execa's
|
|
9
|
+
* {@link https://github.com/sindresorhus/execa/blob/main/docs/scripts.md|script mode}.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* const { stdout } = await $`echo ${"hello"}`;
|
|
14
|
+
* console.log(`stdout = ${stdout}`);
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @see {@link https://github.com/sindresorhus/execa/blob/main/docs/execution.md#%EF%B8%8F-basic-execution|Execa docs}
|
|
18
|
+
*/
|
|
19
|
+
$: ExecaScriptMethod;
|
|
20
|
+
/**
|
|
21
|
+
* Run a command using {@link https://github.com/sindresorhus/execa|Execa}
|
|
22
|
+
* (e.g., shell command or script).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const { stdout } = await execa`echo 'hello'`;
|
|
27
|
+
* console.log(stdout);
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @see {@link https://github.com/sindresorhus/execa/blob/main/docs/execution.md#%EF%B8%8F-basic-execution|Execa docs}
|
|
31
|
+
*/
|
|
32
|
+
command: ExecaMethod;
|
|
33
|
+
/**
|
|
34
|
+
* Same as `command`, but inherits the parent process' stdio streams by
|
|
35
|
+
* default, i.e., logs and errors will be sent directly to the terminal.
|
|
36
|
+
* Use `command` or `$` if you want to capture the output instead.
|
|
37
|
+
*/
|
|
38
|
+
exec: ExecaMethod<{
|
|
39
|
+
stdin: "inherit";
|
|
40
|
+
stdout: "inherit";
|
|
41
|
+
stderr: "inherit";
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
type DefaultOptionsInput = ParsedOptions & {
|
|
45
|
+
env: Record<string, string | undefined>;
|
|
46
|
+
};
|
|
47
|
+
type MergePositionals<TOptions> = "_" extends keyof TOptions ? TOptions : TOptions & ParsedOptions;
|
|
48
|
+
type MergeEnvironment<TOptions> = "env" extends keyof TOptions ? TOptions : TOptions & Pick<DefaultOptionsInput, "env">;
|
|
49
|
+
type Task<TOptions = unknown> = (options: MergePositionals<MergeEnvironment<TOptions>>, utilities: TaskUtilities) => unknown;
|
|
50
|
+
type BrandedTaskStrict<TShape = DefaultOptionsInput> = Task<TShape> & {
|
|
51
|
+
kind: "strictTask";
|
|
52
|
+
schema: Type<TShape>;
|
|
53
|
+
};
|
|
54
|
+
type BrandedTaskLoose<TOptions = unknown> = Task<TOptions> & {
|
|
55
|
+
kind: "task";
|
|
56
|
+
schema: never;
|
|
57
|
+
};
|
|
58
|
+
type BrandedTask<TOptions = unknown> = BrandedTaskLoose<TOptions> | BrandedTaskStrict<TOptions>;
|
|
59
|
+
//#endregion
|
|
60
|
+
export { BrandedTask, BrandedTaskStrict, DefaultOptionsInput, Task };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { reservedNames } from "../cli/parseOptions.mjs";
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
//#region src/util/getSchemaProperties.ts
|
|
5
|
+
const propertyEntry = type({
|
|
6
|
+
key: "string",
|
|
7
|
+
"+": "delete"
|
|
8
|
+
});
|
|
9
|
+
const arktypeJson = type({
|
|
10
|
+
domain: "string",
|
|
11
|
+
required: propertyEntry.array().default(() => []),
|
|
12
|
+
optional: propertyEntry.array().default(() => [])
|
|
13
|
+
});
|
|
14
|
+
const getSchemaProperties = (schema) => {
|
|
15
|
+
const definition = arktypeJson.assert(schema.json);
|
|
16
|
+
if (definition instanceof type.errors || definition.domain !== "object") return "";
|
|
17
|
+
const { required, optional } = definition;
|
|
18
|
+
const requiredProperties = required.map(({ key }) => ({
|
|
19
|
+
key,
|
|
20
|
+
isOptional: false
|
|
21
|
+
}));
|
|
22
|
+
const optionalProperties = optional.map(({ key }) => ({
|
|
23
|
+
key,
|
|
24
|
+
isOptional: true
|
|
25
|
+
}));
|
|
26
|
+
const properties = [...requiredProperties, ...optionalProperties];
|
|
27
|
+
if (properties.length === 0) return "";
|
|
28
|
+
return `{ ${properties.filter(({ key }) => !reservedNames.has(key)).map(({ key, isOptional }) => {
|
|
29
|
+
if (isOptional) return styleText("dim", `[${key}]`);
|
|
30
|
+
return key;
|
|
31
|
+
}).join(", ")} }`;
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { getSchemaProperties };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { loadConfig } from "c12";
|
|
3
|
+
//#region src/util/loadTaskFile.ts
|
|
4
|
+
const loadTaskFile = async (context) => {
|
|
5
|
+
try {
|
|
6
|
+
const collection = await loadConfig({
|
|
7
|
+
cwd: context.taskFile.dir,
|
|
8
|
+
name: context.taskFile.name,
|
|
9
|
+
configFile: context.taskFile.base,
|
|
10
|
+
dotenv: false,
|
|
11
|
+
rcFile: false,
|
|
12
|
+
packageJson: false,
|
|
13
|
+
jitiOptions: { tsconfigPaths: true }
|
|
14
|
+
});
|
|
15
|
+
if (!collection.configFile || collection.layers?.length === 0) return {
|
|
16
|
+
ok: false,
|
|
17
|
+
error: /* @__PURE__ */ new Error(`Failed to load task file at path ${resolve(context.taskFile.dir, context.taskFile.base)}`)
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
ok: true,
|
|
21
|
+
value: collection
|
|
22
|
+
};
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { loadTaskFile };
|