launchr-cli 1.0.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/.github/workflows/publish-npm.yml +78 -0
- package/README.md +150 -0
- package/RELEASE_NOTES_v1.0.0.md +11 -0
- package/assets/launchr-logo.png +0 -0
- package/package.json +19 -0
- package/src/cli.mjs +127 -0
- package/src/commands/help.mjs +101 -0
- package/src/commands/init.mjs +198 -0
- package/src/commands/list.mjs +10 -0
- package/src/commands/run-custom.mjs +24 -0
- package/src/config/paths.mjs +10 -0
- package/src/config/schema.mjs +136 -0
- package/src/config/store.mjs +82 -0
- package/src/constants.mjs +14 -0
- package/src/parsing/argv.mjs +7 -0
- package/src/parsing/flags.mjs +33 -0
- package/src/utils/browser.mjs +69 -0
- package/src/utils/errors.mjs +26 -0
- package/src/utils/prompt.mjs +55 -0
- package/src/utils/url-template.mjs +36 -0
- package/src/validation/runtime-params.mjs +95 -0
- package/test/browser.test.mjs +39 -0
- package/test/cli.integration.test.mjs +152 -0
- package/test/flags.test.mjs +18 -0
- package/test/init.test.mjs +52 -0
- package/test/runtime-params.test.mjs +63 -0
- package/test/schema.test.mjs +101 -0
- package/test/url-template.test.mjs +30 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parseShortFlags } from "../parsing/flags.mjs";
|
|
2
|
+
import { interpolateUrlTemplate } from "../utils/url-template.mjs";
|
|
3
|
+
import { resolveRuntimeParameters } from "../validation/runtime-params.mjs";
|
|
4
|
+
|
|
5
|
+
export async function runCustomCommand({
|
|
6
|
+
commandName,
|
|
7
|
+
commandConfig,
|
|
8
|
+
argv,
|
|
9
|
+
openUrl,
|
|
10
|
+
}) {
|
|
11
|
+
const valuesByFlag = parseShortFlags(argv);
|
|
12
|
+
const { valuesByParameterKey } = resolveRuntimeParameters(
|
|
13
|
+
commandName,
|
|
14
|
+
commandConfig,
|
|
15
|
+
valuesByFlag,
|
|
16
|
+
);
|
|
17
|
+
const finalUrl = interpolateUrlTemplate(commandConfig.url, valuesByParameterKey);
|
|
18
|
+
await openUrl(finalUrl);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
finalUrl,
|
|
22
|
+
valuesByParameterKey,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function getConfigDirPath(homeDir = os.homedir()) {
|
|
5
|
+
return path.join(homeDir, ".launchr-configurations");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getConfigFilePath(homeDir = os.homedir()) {
|
|
9
|
+
return path.join(getConfigDirPath(homeDir), "launchr-commands.json");
|
|
10
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { PARAMETER_TYPES } from "../constants.mjs";
|
|
2
|
+
import { ValidationError } from "../utils/errors.mjs";
|
|
3
|
+
import { extractTemplatePlaceholders } from "../utils/url-template.mjs";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_JSON_SCHEMA = {
|
|
6
|
+
type: "object",
|
|
7
|
+
additionalProperties: {
|
|
8
|
+
type: "object",
|
|
9
|
+
required: ["description", "url", "parameters"],
|
|
10
|
+
properties: {
|
|
11
|
+
description: { type: "string" },
|
|
12
|
+
url: { type: "string" },
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
additionalProperties: {
|
|
16
|
+
type: "object",
|
|
17
|
+
required: ["type", "flag", "required"],
|
|
18
|
+
properties: {
|
|
19
|
+
type: { enum: PARAMETER_TYPES },
|
|
20
|
+
flag: { type: "string", minLength: 1 },
|
|
21
|
+
required: { type: "boolean" },
|
|
22
|
+
defaultValue: {},
|
|
23
|
+
values: { type: "array" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value) {
|
|
32
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assert(condition, message) {
|
|
36
|
+
if (!condition) {
|
|
37
|
+
throw new ValidationError(message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateFlag(flag, location) {
|
|
42
|
+
assert(typeof flag === "string" && flag.trim().length > 0, `${location}.flag must be a non-empty string`);
|
|
43
|
+
const normalized = flag.replace(/^-+/, "").trim();
|
|
44
|
+
assert(/^[a-zA-Z]$/.test(normalized), `${location}.flag must be a single alphabetic character`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateTemplatePlaceholders(commandName, commandConfig) {
|
|
48
|
+
const commandLocation = `Command "${commandName}"`;
|
|
49
|
+
const parameterKeys = Object.keys(commandConfig.parameters);
|
|
50
|
+
const parameterKeySet = new Set(parameterKeys);
|
|
51
|
+
|
|
52
|
+
assert(
|
|
53
|
+
!commandConfig.url.includes("%s"),
|
|
54
|
+
`${commandLocation}.url uses deprecated "%s" placeholders. Use named placeholders like {query}.`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const rawPlaceholderTokens = commandConfig.url.match(/{[^}]+}/g) ?? [];
|
|
58
|
+
for (const rawToken of rawPlaceholderTokens) {
|
|
59
|
+
const tokenBody = rawToken.slice(1, -1);
|
|
60
|
+
assert(
|
|
61
|
+
/^[a-zA-Z0-9_-]+$/.test(tokenBody),
|
|
62
|
+
`${commandLocation}.url has invalid placeholder ${rawToken}. Use {parameter_key} with letters, numbers, _ or -.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const placeholderNames = extractTemplatePlaceholders(commandConfig.url);
|
|
67
|
+
const placeholderNameSet = new Set(placeholderNames);
|
|
68
|
+
|
|
69
|
+
const unknownPlaceholders = [...placeholderNameSet].filter((name) => !parameterKeySet.has(name));
|
|
70
|
+
assert(
|
|
71
|
+
unknownPlaceholders.length === 0,
|
|
72
|
+
`${commandLocation}.url has unknown placeholders: ${unknownPlaceholders.map((name) => `{${name}}`).join(", ")}`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const missingPlaceholders = parameterKeys.filter((key) => !placeholderNameSet.has(key));
|
|
76
|
+
assert(
|
|
77
|
+
missingPlaceholders.length === 0,
|
|
78
|
+
`${commandLocation}.url is missing placeholders for parameters: ${missingPlaceholders.map((name) => `{${name}}`).join(", ")}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateSingleCommand(commandName, commandConfig) {
|
|
83
|
+
const commandLocation = `Command "${commandName}"`;
|
|
84
|
+
assert(isPlainObject(commandConfig), `${commandLocation} must be an object`);
|
|
85
|
+
assert(typeof commandConfig.description === "string", `${commandLocation}.description must be a string`);
|
|
86
|
+
assert(typeof commandConfig.url === "string", `${commandLocation}.url must be a string`);
|
|
87
|
+
assert(isPlainObject(commandConfig.parameters), `${commandLocation}.parameters must be an object`);
|
|
88
|
+
|
|
89
|
+
const seenFlags = new Set();
|
|
90
|
+
|
|
91
|
+
for (const [paramName, paramDef] of Object.entries(commandConfig.parameters)) {
|
|
92
|
+
const parameterLocation = `${commandLocation}.parameters.${paramName}`;
|
|
93
|
+
assert(isPlainObject(paramDef), `${parameterLocation} must be an object`);
|
|
94
|
+
assert(typeof paramDef.type === "string", `${parameterLocation}.type must be a string`);
|
|
95
|
+
assert(PARAMETER_TYPES.includes(paramDef.type), `${parameterLocation}.type must be one of: ${PARAMETER_TYPES.join(", ")}`);
|
|
96
|
+
validateFlag(paramDef.flag, parameterLocation);
|
|
97
|
+
assert(typeof paramDef.required === "boolean", `${parameterLocation}.required must be a boolean`);
|
|
98
|
+
|
|
99
|
+
const normalizedFlag = paramDef.flag.replace(/^-+/, "");
|
|
100
|
+
assert(!seenFlags.has(normalizedFlag), `${commandLocation} has duplicated flag "-${normalizedFlag}"`);
|
|
101
|
+
seenFlags.add(normalizedFlag);
|
|
102
|
+
|
|
103
|
+
if (paramDef.type === "single-choice-list") {
|
|
104
|
+
assert(Array.isArray(paramDef.values), `${parameterLocation}.values must be an array for single-choice-list`);
|
|
105
|
+
assert(paramDef.values.length > 0, `${parameterLocation}.values must contain at least one value`);
|
|
106
|
+
for (const value of paramDef.values) {
|
|
107
|
+
assert(typeof value === "string" && value.length > 0, `${parameterLocation}.values must contain non-empty strings`);
|
|
108
|
+
}
|
|
109
|
+
if (paramDef.defaultValue !== undefined && paramDef.defaultValue !== null) {
|
|
110
|
+
const defaultValue = String(paramDef.defaultValue);
|
|
111
|
+
assert(paramDef.values.includes(defaultValue), `${parameterLocation}.defaultValue must be one of the allowed values`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (paramDef.type === "integer" && paramDef.defaultValue !== undefined && paramDef.defaultValue !== null) {
|
|
116
|
+
const integerPattern = /^-?\d+$/;
|
|
117
|
+
assert(integerPattern.test(String(paramDef.defaultValue)), `${parameterLocation}.defaultValue must be an integer`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (paramDef.type === "boolean" && paramDef.defaultValue !== undefined && paramDef.defaultValue !== null) {
|
|
121
|
+
const raw = String(paramDef.defaultValue).toLowerCase();
|
|
122
|
+
const validBoolean = raw === "true" || raw === "false" || raw === "1" || raw === "0";
|
|
123
|
+
assert(validBoolean, `${parameterLocation}.defaultValue must be a boolean (true/false/1/0)`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
validateTemplatePlaceholders(commandName, commandConfig);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function validateConfigSchema(config) {
|
|
131
|
+
assert(isPlainObject(config), "Configuration root must be a JSON object");
|
|
132
|
+
for (const [commandName, commandConfig] of Object.entries(config)) {
|
|
133
|
+
validateSingleCommand(commandName, commandConfig);
|
|
134
|
+
}
|
|
135
|
+
return config;
|
|
136
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_PROMPT_TEXT, CONFIG_REQUIRED_MESSAGE } from "../constants.mjs";
|
|
4
|
+
import { ConfigError } from "../utils/errors.mjs";
|
|
5
|
+
import { validateConfigSchema } from "./schema.mjs";
|
|
6
|
+
|
|
7
|
+
async function pathExists(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
await stat(filePath);
|
|
10
|
+
return true;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error && error.code === "ENOENT") {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function createEmptyConfiguration(configDirPath, configFilePath) {
|
|
20
|
+
await mkdir(configDirPath, { recursive: true });
|
|
21
|
+
await writeFile(configFilePath, "{}\n", "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ensureConfigExistsOrPromptCreate(options) {
|
|
25
|
+
const {
|
|
26
|
+
configDirPath,
|
|
27
|
+
configFilePath,
|
|
28
|
+
promptYesNo,
|
|
29
|
+
promptText = CONFIG_PROMPT_TEXT,
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
const exists = await pathExists(configFilePath);
|
|
33
|
+
if (exists) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const shouldCreate = await promptYesNo(promptText);
|
|
38
|
+
if (!shouldCreate) {
|
|
39
|
+
throw new ConfigError(CONFIG_REQUIRED_MESSAGE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await createEmptyConfiguration(configDirPath, configFilePath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function loadConfiguration(configFilePath) {
|
|
46
|
+
let rawText;
|
|
47
|
+
try {
|
|
48
|
+
rawText = await readFile(configFilePath, "utf8");
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error && error.code === "ENOENT") {
|
|
51
|
+
throw new ConfigError(`Configuration file not found at ${configFilePath}`);
|
|
52
|
+
}
|
|
53
|
+
throw new ConfigError(`Unable to read configuration file at ${configFilePath}`, { cause: error });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = rawText.trim().length === 0 ? {} : JSON.parse(rawText);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new ConfigError(`Configuration file is corrupted JSON: ${configFilePath}`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return validateConfigSchema(parsed);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof ConfigError) {
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
throw new ConfigError(`Configuration schema validation failed: ${error.message}`, { cause: error });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function saveConfiguration(configFilePath, configObject) {
|
|
74
|
+
validateConfigSchema(configObject);
|
|
75
|
+
const tempFilePath = path.join(
|
|
76
|
+
path.dirname(configFilePath),
|
|
77
|
+
`${path.basename(configFilePath)}.tmp`,
|
|
78
|
+
);
|
|
79
|
+
const payload = `${JSON.stringify(configObject, null, 2)}\n`;
|
|
80
|
+
await writeFile(tempFilePath, payload, "utf8");
|
|
81
|
+
await rename(tempFilePath, configFilePath);
|
|
82
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const BUILTIN_COMMANDS = ["help", "list", "init"];
|
|
2
|
+
|
|
3
|
+
export const PARAMETER_TYPES = [
|
|
4
|
+
"string",
|
|
5
|
+
"integer",
|
|
6
|
+
"boolean",
|
|
7
|
+
"single-choice-list",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export const CONFIG_PROMPT_TEXT =
|
|
11
|
+
"No configuration found. Do you want to create one? (yes/no)";
|
|
12
|
+
|
|
13
|
+
export const CONFIG_REQUIRED_MESSAGE =
|
|
14
|
+
"Configuration file is required to use this CLI.";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { UsageError } from "../utils/errors.mjs";
|
|
2
|
+
|
|
3
|
+
function isFlagToken(token) {
|
|
4
|
+
return typeof token === "string" && /^-[a-zA-Z]$/.test(token);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseShortFlags(tokens) {
|
|
8
|
+
const providedTokens = Array.isArray(tokens) ? tokens : [];
|
|
9
|
+
const valuesByFlag = new Map();
|
|
10
|
+
|
|
11
|
+
for (let index = 0; index < providedTokens.length; index += 1) {
|
|
12
|
+
const token = providedTokens[index];
|
|
13
|
+
if (!isFlagToken(token)) {
|
|
14
|
+
throw new UsageError(`Unexpected token: "${token}". Only short flags like -q are supported.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const flag = token.slice(1);
|
|
18
|
+
if (valuesByFlag.has(flag)) {
|
|
19
|
+
throw new UsageError(`Flag "-${flag}" was provided more than once.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const nextToken = providedTokens[index + 1];
|
|
23
|
+
if (nextToken === undefined || isFlagToken(nextToken)) {
|
|
24
|
+
valuesByFlag.set(flag, true);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
valuesByFlag.set(flag, nextToken);
|
|
29
|
+
index += 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return valuesByFlag;
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { ValidationError } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
const SPECIAL_SCHEMES = ["mailto:", "tel:", "sms:", "data:"];
|
|
5
|
+
|
|
6
|
+
function hasExplicitScheme(value) {
|
|
7
|
+
return value.includes("://") || SPECIAL_SCHEMES.some((scheme) => value.toLowerCase().startsWith(scheme));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeUrlTarget(rawUrl) {
|
|
11
|
+
const value = String(rawUrl ?? "").trim();
|
|
12
|
+
|
|
13
|
+
if (value.length === 0) {
|
|
14
|
+
throw new ValidationError("URL is empty after interpolation.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (value.startsWith("//")) {
|
|
18
|
+
return `https:${value}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (hasExplicitScheme(value)) {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return `https://${value}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertValidAbsoluteUrl(url) {
|
|
29
|
+
try {
|
|
30
|
+
// URL constructor enforces absolute URL validity.
|
|
31
|
+
// Relative or malformed targets should fail fast with a clear error.
|
|
32
|
+
new URL(url);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ValidationError(`Invalid URL: ${url}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runWithZx(command, url) {
|
|
39
|
+
const { $ } = await import("zx");
|
|
40
|
+
if (command === "open") {
|
|
41
|
+
await $`open ${url}`;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (command === "cmd") {
|
|
45
|
+
await $`cmd /c start "" ${url}`;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await $`xdg-open ${url}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function openInBrowser(url, options = {}) {
|
|
52
|
+
const platform = options.platform ?? process.platform;
|
|
53
|
+
const runCommand = options.runCommand ?? runWithZx;
|
|
54
|
+
|
|
55
|
+
const normalizedUrl = normalizeUrlTarget(url);
|
|
56
|
+
assertValidAbsoluteUrl(normalizedUrl);
|
|
57
|
+
|
|
58
|
+
if (platform === "darwin") {
|
|
59
|
+
await runCommand("open", normalizedUrl);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (platform === "win32") {
|
|
64
|
+
await runCommand("cmd", normalizedUrl);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await runCommand("xdg-open", normalizedUrl);
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
constructor(message, options = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = this.constructor.name;
|
|
5
|
+
this.code = options.code ?? "APP_ERROR";
|
|
6
|
+
this.cause = options.cause;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class UsageError extends AppError {
|
|
11
|
+
constructor(message, options = {}) {
|
|
12
|
+
super(message, { ...options, code: options.code ?? "USAGE_ERROR" });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ConfigError extends AppError {
|
|
17
|
+
constructor(message, options = {}) {
|
|
18
|
+
super(message, { ...options, code: options.code ?? "CONFIG_ERROR" });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ValidationError extends AppError {
|
|
23
|
+
constructor(message, options = {}) {
|
|
24
|
+
super(message, { ...options, code: options.code ?? "VALIDATION_ERROR" });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as defaultInput, stdout as defaultOutput } from "node:process";
|
|
3
|
+
import { ValidationError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
export function createPrompter(options = {}) {
|
|
6
|
+
const input = options.input ?? defaultInput;
|
|
7
|
+
const output = options.output ?? defaultOutput;
|
|
8
|
+
const rl = createInterface({ input, output });
|
|
9
|
+
|
|
10
|
+
let isClosed = false;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
async ask(question) {
|
|
14
|
+
if (isClosed) {
|
|
15
|
+
throw new ValidationError("Prompt session is closed");
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const answer = await rl.question(question);
|
|
19
|
+
return answer.trim();
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new ValidationError("Prompt interrupted");
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
close() {
|
|
25
|
+
if (!isClosed) {
|
|
26
|
+
isClosed = true;
|
|
27
|
+
rl.close();
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
write(text) {
|
|
31
|
+
output.write(text);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function promptYesNo(question, options = {}) {
|
|
37
|
+
const prompter = createPrompter(options);
|
|
38
|
+
try {
|
|
39
|
+
while (true) {
|
|
40
|
+
const answer = (await prompter.ask(`${question} `)).toLowerCase();
|
|
41
|
+
if (answer === "yes" || answer === "y") {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (answer === "no" || answer === "n") {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (answer === "") {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
options.output?.write?.("Please answer yes or no.\n");
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
prompter.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
const PLACEHOLDER_PATTERN = /{([a-zA-Z0-9_-]+)}/g;
|
|
4
|
+
|
|
5
|
+
export function extractTemplatePlaceholders(template) {
|
|
6
|
+
const text = String(template);
|
|
7
|
+
const placeholders = [];
|
|
8
|
+
|
|
9
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
10
|
+
let match = PLACEHOLDER_PATTERN.exec(text);
|
|
11
|
+
while (match !== null) {
|
|
12
|
+
placeholders.push(match[1]);
|
|
13
|
+
match = PLACEHOLDER_PATTERN.exec(text);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return placeholders;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasOwnKey(record, key) {
|
|
20
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function encodeTemplateValue(value) {
|
|
24
|
+
return encodeURIComponent(String(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function interpolateUrlTemplate(template, valuesByParameterKey) {
|
|
28
|
+
const text = String(template);
|
|
29
|
+
return text.replace(PLACEHOLDER_PATTERN, (fullMatch, parameterKey) => {
|
|
30
|
+
if (!hasOwnKey(valuesByParameterKey, parameterKey)) {
|
|
31
|
+
throw new ValidationError(`URL placeholder "{${parameterKey}}" has no matching parameter value.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return encodeTemplateValue(valuesByParameterKey[parameterKey]);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ValidationError } from "../utils/errors.mjs";
|
|
2
|
+
|
|
3
|
+
function parseInteger(rawValue, paramKey) {
|
|
4
|
+
const normalized = String(rawValue);
|
|
5
|
+
if (!/^-?\d+$/.test(normalized)) {
|
|
6
|
+
throw new ValidationError(`Parameter "${paramKey}" must be an integer.`);
|
|
7
|
+
}
|
|
8
|
+
return Number.parseInt(normalized, 10);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseBoolean(rawValue, paramKey) {
|
|
12
|
+
if (typeof rawValue === "boolean") {
|
|
13
|
+
return rawValue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const normalized = String(rawValue).toLowerCase();
|
|
17
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "y") {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "n") {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
throw new ValidationError(`Parameter "${paramKey}" must be boolean (true/false).`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeValueByType(rawValue, parameterKey, parameterDefinition) {
|
|
28
|
+
switch (parameterDefinition.type) {
|
|
29
|
+
case "string":
|
|
30
|
+
return String(rawValue);
|
|
31
|
+
case "integer":
|
|
32
|
+
return parseInteger(rawValue, parameterKey);
|
|
33
|
+
case "boolean":
|
|
34
|
+
return parseBoolean(rawValue, parameterKey);
|
|
35
|
+
case "single-choice-list": {
|
|
36
|
+
const stringValue = String(rawValue);
|
|
37
|
+
if (!parameterDefinition.values.includes(stringValue)) {
|
|
38
|
+
throw new ValidationError(
|
|
39
|
+
`Parameter "${parameterKey}" must be one of: ${parameterDefinition.values.join(", ")}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return stringValue;
|
|
43
|
+
}
|
|
44
|
+
default:
|
|
45
|
+
throw new ValidationError(`Unsupported parameter type: ${parameterDefinition.type}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasUsableDefault(definition) {
|
|
50
|
+
return definition.defaultValue !== undefined && definition.defaultValue !== null && definition.defaultValue !== "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveRuntimeParameters(commandName, commandConfig, valuesByFlag) {
|
|
54
|
+
const parameterEntries = Object.entries(commandConfig.parameters);
|
|
55
|
+
const allowedFlags = new Set(parameterEntries.map(([, definition]) => definition.flag.replace(/^-+/, "")));
|
|
56
|
+
|
|
57
|
+
for (const incomingFlag of valuesByFlag.keys()) {
|
|
58
|
+
if (!allowedFlags.has(incomingFlag)) {
|
|
59
|
+
throw new ValidationError(`Unknown flag "-${incomingFlag}" for command "${commandName}".`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const valuesInOrder = [];
|
|
64
|
+
const valuesByParameterKey = {};
|
|
65
|
+
|
|
66
|
+
for (const [parameterKey, definition] of parameterEntries) {
|
|
67
|
+
const normalizedFlag = definition.flag.replace(/^-+/, "");
|
|
68
|
+
let rawValue = valuesByFlag.get(normalizedFlag);
|
|
69
|
+
|
|
70
|
+
if (rawValue === true && definition.type !== "boolean") {
|
|
71
|
+
throw new ValidationError(`Flag "-${normalizedFlag}" requires a value.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (rawValue === undefined) {
|
|
75
|
+
if (hasUsableDefault(definition)) {
|
|
76
|
+
rawValue = definition.defaultValue;
|
|
77
|
+
} else if (definition.required) {
|
|
78
|
+
throw new ValidationError(`Missing required parameter "${parameterKey}" (-${normalizedFlag}).`);
|
|
79
|
+
} else {
|
|
80
|
+
valuesByParameterKey[parameterKey] = "";
|
|
81
|
+
valuesInOrder.push("");
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const normalizedValue = normalizeValueByType(rawValue, parameterKey, definition);
|
|
87
|
+
valuesByParameterKey[parameterKey] = normalizedValue;
|
|
88
|
+
valuesInOrder.push(normalizedValue);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
valuesInOrder,
|
|
93
|
+
valuesByParameterKey,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
normalizeUrlTarget,
|
|
5
|
+
assertValidAbsoluteUrl,
|
|
6
|
+
openInBrowser,
|
|
7
|
+
} from "../src/utils/browser.mjs";
|
|
8
|
+
|
|
9
|
+
test("normalizeUrlTarget adds https to scheme-less host paths", () => {
|
|
10
|
+
assert.equal(normalizeUrlTarget("test.com/asd"), "https://test.com/asd");
|
|
11
|
+
assert.equal(normalizeUrlTarget("localhost:3000/docs"), "https://localhost:3000/docs");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("normalizeUrlTarget preserves explicit schemes", () => {
|
|
15
|
+
assert.equal(normalizeUrlTarget("https://test.com/asd"), "https://test.com/asd");
|
|
16
|
+
assert.equal(normalizeUrlTarget("mailto:user@example.com"), "mailto:user@example.com");
|
|
17
|
+
assert.equal(normalizeUrlTarget("//cdn.example.com/lib.js"), "https://cdn.example.com/lib.js");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("normalizeUrlTarget rejects empty input", () => {
|
|
21
|
+
assert.throws(() => normalizeUrlTarget(" "), /URL is empty/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("assertValidAbsoluteUrl rejects malformed URLs", () => {
|
|
25
|
+
assert.throws(() => assertValidAbsoluteUrl("http://"), /Invalid URL/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("openInBrowser invokes platform opener with normalized URL", async () => {
|
|
29
|
+
const calls = [];
|
|
30
|
+
|
|
31
|
+
await openInBrowser("test.com/asd", {
|
|
32
|
+
platform: "darwin",
|
|
33
|
+
runCommand: async (command, url) => {
|
|
34
|
+
calls.push({ command, url });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.deepEqual(calls, [{ command: "open", url: "https://test.com/asd" }]);
|
|
39
|
+
});
|