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,78 @@
|
|
|
1
|
+
name: Publish npm Package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
release:
|
|
8
|
+
types:
|
|
9
|
+
- published
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
publish:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
concurrency:
|
|
18
|
+
group: npm-publish-${{ github.event.release.tag_name || github.ref_name }}
|
|
19
|
+
cancel-in-progress: false
|
|
20
|
+
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout repository
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Setup Node.js
|
|
26
|
+
uses: actions/setup-node@v4
|
|
27
|
+
with:
|
|
28
|
+
node-version: 20
|
|
29
|
+
registry-url: https://registry.npmjs.org
|
|
30
|
+
cache: npm
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: npm ci
|
|
34
|
+
|
|
35
|
+
- name: Run tests
|
|
36
|
+
run: npm test
|
|
37
|
+
|
|
38
|
+
- name: Resolve tag name
|
|
39
|
+
id: tag
|
|
40
|
+
run: |
|
|
41
|
+
if [ "${{ github.event_name }}" = "release" ]; then
|
|
42
|
+
TAG="${{ github.event.release.tag_name }}"
|
|
43
|
+
else
|
|
44
|
+
TAG="${{ github.ref_name }}"
|
|
45
|
+
fi
|
|
46
|
+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
47
|
+
|
|
48
|
+
- name: Verify tag matches package version
|
|
49
|
+
run: |
|
|
50
|
+
PKG_VERSION="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")"
|
|
51
|
+
EXPECTED_TAG="v$PKG_VERSION"
|
|
52
|
+
|
|
53
|
+
if [ "${{ steps.tag.outputs.tag }}" != "$EXPECTED_TAG" ]; then
|
|
54
|
+
echo "Tag ${{ steps.tag.outputs.tag }} does not match package version $EXPECTED_TAG."
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Check if this version already exists on npm
|
|
59
|
+
id: npm_check
|
|
60
|
+
run: |
|
|
61
|
+
PKG_NAME="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).name")"
|
|
62
|
+
PKG_VERSION="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")"
|
|
63
|
+
|
|
64
|
+
if npm view "${PKG_NAME}@${PKG_VERSION}" version >/dev/null 2>&1; then
|
|
65
|
+
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
|
66
|
+
else
|
|
67
|
+
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
- name: Publish to npm
|
|
71
|
+
if: steps.npm_check.outputs.already_published != 'true'
|
|
72
|
+
run: npm publish --access public
|
|
73
|
+
env:
|
|
74
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
75
|
+
|
|
76
|
+
- name: Skip publish (already published)
|
|
77
|
+
if: steps.npm_check.outputs.already_published == 'true'
|
|
78
|
+
run: echo "This package version is already published. Skipping."
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# launchr CLI
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/launchr-logo.png" alt="launchr logo" width="420">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
`launchr` is a configuration-driven CLI to build URLs from typed parameters and open them in your default browser.
|
|
8
|
+
|
|
9
|
+
Built with:
|
|
10
|
+
- Node.js (ESM)
|
|
11
|
+
- `zx`
|
|
12
|
+
- `fs/promises`
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
- Node.js 20+
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
```bash
|
|
19
|
+
npm install
|
|
20
|
+
npm link
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
After linking, `launchr` is available in your shell.
|
|
24
|
+
|
|
25
|
+
## Publish to npm (GitHub Actions)
|
|
26
|
+
This repository includes an automated workflow at:
|
|
27
|
+
|
|
28
|
+
`.github/workflows/publish-npm.yml`
|
|
29
|
+
|
|
30
|
+
It publishes to npm when you either:
|
|
31
|
+
- push a tag matching `v*`
|
|
32
|
+
- publish a GitHub Release
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
- tag must match package version in `package.json` (example: `v1.2.3`)
|
|
36
|
+
- tests must pass before publish
|
|
37
|
+
- if that version already exists on npm, publish is skipped
|
|
38
|
+
|
|
39
|
+
Required GitHub secret:
|
|
40
|
+
- `NPM_TOKEN`: npm automation token with publish access
|
|
41
|
+
|
|
42
|
+
Typical release flow:
|
|
43
|
+
```bash
|
|
44
|
+
npm version patch
|
|
45
|
+
git push origin main --follow-tags
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration Location
|
|
49
|
+
`launchr` uses:
|
|
50
|
+
|
|
51
|
+
`~/.launchr-configurations/launchr-commands.json`
|
|
52
|
+
|
|
53
|
+
If the file is missing, the CLI prompts:
|
|
54
|
+
|
|
55
|
+
`No configuration found. Do you want to create one? (yes/no)`
|
|
56
|
+
|
|
57
|
+
If declined, the CLI exits with:
|
|
58
|
+
|
|
59
|
+
`Configuration file is required to use this CLI.`
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
```bash
|
|
63
|
+
launchr
|
|
64
|
+
launchr help
|
|
65
|
+
launchr list
|
|
66
|
+
launchr init
|
|
67
|
+
launchr <custom-command> help
|
|
68
|
+
launchr <custom-command> [flags]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Interactive Setup
|
|
72
|
+
Run:
|
|
73
|
+
```bash
|
|
74
|
+
launchr init
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
You will be asked for:
|
|
78
|
+
- command name
|
|
79
|
+
- description
|
|
80
|
+
- URL template (with named placeholders like `{query}`)
|
|
81
|
+
- parameters (loop until `done`)
|
|
82
|
+
- key
|
|
83
|
+
- type (`string`, `integer`, `boolean`, `single-choice-list`)
|
|
84
|
+
- flag (`q` means `-q`)
|
|
85
|
+
- required (`true/false`)
|
|
86
|
+
- default value
|
|
87
|
+
- allowed values (for `single-choice-list`)
|
|
88
|
+
|
|
89
|
+
Type `finish` when asked for command name to stop immediately.
|
|
90
|
+
|
|
91
|
+
## JSON Example
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"grafana": {
|
|
95
|
+
"description": "some useful information",
|
|
96
|
+
"url": "https://grafana.com/{environments}/{query}/{timeframe}",
|
|
97
|
+
"parameters": {
|
|
98
|
+
"environments": {
|
|
99
|
+
"type": "single-choice-list",
|
|
100
|
+
"flag": "e",
|
|
101
|
+
"defaultValue": "staging",
|
|
102
|
+
"required": true,
|
|
103
|
+
"values": [
|
|
104
|
+
"staging",
|
|
105
|
+
"production"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"query": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"flag": "q",
|
|
111
|
+
"defaultValue": "error",
|
|
112
|
+
"required": true
|
|
113
|
+
},
|
|
114
|
+
"timeframe": {
|
|
115
|
+
"type": "single-choice-list",
|
|
116
|
+
|
|
117
|
+
"flag": "t",
|
|
118
|
+
"defaultValue": "5m",
|
|
119
|
+
"required": true,
|
|
120
|
+
"values": [
|
|
121
|
+
"5m",
|
|
122
|
+
"10m",
|
|
123
|
+
"1h",
|
|
124
|
+
"6h"
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Usage Examples
|
|
133
|
+
```bash
|
|
134
|
+
launchr list
|
|
135
|
+
launchr grafana help
|
|
136
|
+
launchr grafana -e production -q error -t 5m
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Validation and Errors
|
|
140
|
+
- schema validation on config load
|
|
141
|
+
- malformed JSON detection
|
|
142
|
+
- required parameter checks
|
|
143
|
+
- type checks (`string`, `integer`, `boolean`, `single-choice-list`)
|
|
144
|
+
- allowed-value checks for `single-choice-list`
|
|
145
|
+
- URL placeholder count checks
|
|
146
|
+
|
|
147
|
+
## Tests
|
|
148
|
+
```bash
|
|
149
|
+
npm test
|
|
150
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# launchr v1.0.0
|
|
2
|
+
|
|
3
|
+
First stable release of `launchr`, a config-driven CLI to build URLs from typed flags and open them in your default browser.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
- Interactive setup with `launchr init` to create custom commands and URL templates.
|
|
8
|
+
- Typed parameter support: `string`, `integer`, `boolean`, `single-choice-list`.
|
|
9
|
+
- Runtime checks for required params, unknown flags, invalid values, and URL placeholders.
|
|
10
|
+
- URL interpolation with encoding plus cross-platform browser launch (`open` / `cmd start` / `xdg-open`).
|
|
11
|
+
- Automated npm publish workflow with test gate and version/tag verification.
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "launchr-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Config-driven URL launcher CLI built with Node.js + zx",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"launchr": "./src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node ./src/cli.mjs",
|
|
11
|
+
"test": "node --test"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.0.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"zx": "^8.8.4"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { buildGeneralHelp, buildCommandHelp, buildUsageText } from "./commands/help.mjs";
|
|
8
|
+
import { runInitFlow } from "./commands/init.mjs";
|
|
9
|
+
import { buildCommandList } from "./commands/list.mjs";
|
|
10
|
+
import { runCustomCommand } from "./commands/run-custom.mjs";
|
|
11
|
+
import { getConfigDirPath, getConfigFilePath } from "./config/paths.mjs";
|
|
12
|
+
import {
|
|
13
|
+
ensureConfigExistsOrPromptCreate,
|
|
14
|
+
loadConfiguration,
|
|
15
|
+
saveConfiguration,
|
|
16
|
+
} from "./config/store.mjs";
|
|
17
|
+
import { parseArgv } from "./parsing/argv.mjs";
|
|
18
|
+
import { promptYesNo, createPrompter } from "./utils/prompt.mjs";
|
|
19
|
+
import { UsageError } from "./utils/errors.mjs";
|
|
20
|
+
import { openInBrowser } from "./utils/browser.mjs";
|
|
21
|
+
|
|
22
|
+
function writeLine(stream, message = "") {
|
|
23
|
+
stream.write(`${message}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
27
|
+
const stdout = options.stdout ?? process.stdout;
|
|
28
|
+
const stderr = options.stderr ?? process.stderr;
|
|
29
|
+
const input = options.input ?? process.stdin;
|
|
30
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
31
|
+
const promptYesNoFn = options.promptYesNoFn ?? promptYesNo;
|
|
32
|
+
const createPrompterFn = options.createPrompterFn ?? createPrompter;
|
|
33
|
+
const openUrlFn = options.openUrlFn ?? openInBrowser;
|
|
34
|
+
|
|
35
|
+
const configDirPath = getConfigDirPath(homeDir);
|
|
36
|
+
const configFilePath = getConfigFilePath(homeDir);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await ensureConfigExistsOrPromptCreate({
|
|
40
|
+
configDirPath,
|
|
41
|
+
configFilePath,
|
|
42
|
+
promptYesNo: (question) => promptYesNoFn(question, { input, output: stdout }),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let config = await loadConfiguration(configFilePath);
|
|
46
|
+
const parsed = parseArgv(argv);
|
|
47
|
+
|
|
48
|
+
if (!parsed.command) {
|
|
49
|
+
writeLine(stdout, buildUsageText(config));
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (parsed.command === "help") {
|
|
54
|
+
writeLine(stdout, buildGeneralHelp(config));
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parsed.command === "list") {
|
|
59
|
+
writeLine(stdout, buildCommandList(config));
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsed.command === "init") {
|
|
64
|
+
const prompter = createPrompterFn({ input, output: stdout });
|
|
65
|
+
try {
|
|
66
|
+
config = await runInitFlow({
|
|
67
|
+
config,
|
|
68
|
+
prompter,
|
|
69
|
+
saveConfig: (nextConfig) => saveConfiguration(configFilePath, nextConfig),
|
|
70
|
+
});
|
|
71
|
+
if (Object.keys(config).length > 0) {
|
|
72
|
+
writeLine(stdout, buildCommandList(config));
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
prompter.close();
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const commandConfig = config[parsed.command];
|
|
81
|
+
if (!commandConfig) {
|
|
82
|
+
throw new UsageError(`Unknown command "${parsed.command}". Run "launchr list" to see available commands.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (parsed.rest[0] === "help") {
|
|
86
|
+
writeLine(stdout, buildCommandHelp(parsed.command, commandConfig));
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await runCustomCommand({
|
|
91
|
+
commandName: parsed.command,
|
|
92
|
+
commandConfig,
|
|
93
|
+
argv: parsed.rest,
|
|
94
|
+
openUrl: openUrlFn,
|
|
95
|
+
});
|
|
96
|
+
writeLine(stdout, `Opening URL: ${result.finalUrl}`);
|
|
97
|
+
return 0;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
writeLine(stderr, error.message);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isDirectInvocation() {
|
|
105
|
+
if (!process.argv[1]) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const argvPath = process.argv[1];
|
|
110
|
+
const argvHref = pathToFileURL(argvPath).href;
|
|
111
|
+
if (import.meta.url === argvHref) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const resolvedArgvPath = realpathSync(argvPath);
|
|
117
|
+
const resolvedArgvHref = pathToFileURL(resolvedArgvPath).href;
|
|
118
|
+
return import.meta.url === resolvedArgvHref;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isDirectInvocation()) {
|
|
125
|
+
const code = await runCli();
|
|
126
|
+
process.exitCode = code;
|
|
127
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const BUILTIN_COMMANDS = [
|
|
2
|
+
{ name: "help", description: "Show manual" },
|
|
3
|
+
{ name: "list", description: "List available commands" },
|
|
4
|
+
{ name: "init", description: "Interactive setup" },
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
function formatRequired(required) {
|
|
8
|
+
return required ? "required" : "optional";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatParameterType(parameterDefinition) {
|
|
12
|
+
if (parameterDefinition.type === "single-choice-list") {
|
|
13
|
+
return `Allowed values: ${parameterDefinition.values.join(", ")}`;
|
|
14
|
+
}
|
|
15
|
+
return `Type: ${parameterDefinition.type}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeDescription(rawDescription) {
|
|
19
|
+
if (typeof rawDescription !== "string") {
|
|
20
|
+
return "No description";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const trimmed = rawDescription.trim();
|
|
24
|
+
return trimmed.length > 0 ? trimmed : "No description";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatCommandRows(commandRows) {
|
|
28
|
+
if (commandRows.length === 0) {
|
|
29
|
+
return [" (none)"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const nameWidth = commandRows.reduce((maxWidth, row) => {
|
|
33
|
+
return Math.max(maxWidth, row.name.length);
|
|
34
|
+
}, 0);
|
|
35
|
+
|
|
36
|
+
return commandRows.map((row) => {
|
|
37
|
+
return ` ${row.name.padEnd(nameWidth)} ${row.description}`;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildUsageText(config = {}) {
|
|
42
|
+
const customCommandRows = Object.entries(config).map(([commandName, commandConfig]) => {
|
|
43
|
+
return {
|
|
44
|
+
name: commandName,
|
|
45
|
+
description: normalizeDescription(commandConfig?.description),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
"Usage: launchr <command> [options]",
|
|
51
|
+
"",
|
|
52
|
+
"Built-in Commands:",
|
|
53
|
+
...formatCommandRows(BUILTIN_COMMANDS),
|
|
54
|
+
"",
|
|
55
|
+
"Custom Commands:",
|
|
56
|
+
...formatCommandRows(customCommandRows),
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildGeneralHelp(config = {}) {
|
|
61
|
+
const usageText = buildUsageText(config);
|
|
62
|
+
return [
|
|
63
|
+
usageText,
|
|
64
|
+
"",
|
|
65
|
+
"How to define commands:",
|
|
66
|
+
" Run `launchr init` and follow prompts to create command metadata, URL templates, and parameters.",
|
|
67
|
+
"",
|
|
68
|
+
"How parameters work:",
|
|
69
|
+
" Each parameter has a type, a short flag, required/default rules, and optional allowed values.",
|
|
70
|
+
" URL templates use named placeholders like `{query}` mapped by parameter key.",
|
|
71
|
+
"",
|
|
72
|
+
"Examples:",
|
|
73
|
+
" launchr list",
|
|
74
|
+
" launchr grafana help",
|
|
75
|
+
" launchr grafana -e production -q error -t 5m",
|
|
76
|
+
].join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildCommandHelp(commandName, commandConfig) {
|
|
80
|
+
const parameterEntries = Object.entries(commandConfig.parameters);
|
|
81
|
+
|
|
82
|
+
const usageTokens = parameterEntries.map(([paramName, definition]) => {
|
|
83
|
+
const token = `-${definition.flag.replace(/^-+/, "")} <${paramName}>`;
|
|
84
|
+
return definition.required ? token : `[${token}]`;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const optionLines = [];
|
|
88
|
+
for (const [parameterName, definition] of parameterEntries) {
|
|
89
|
+
const normalizedFlag = definition.flag.replace(/^-+/, "");
|
|
90
|
+
optionLines.push(` -${normalizedFlag} ${parameterName} (${formatRequired(definition.required)})`);
|
|
91
|
+
optionLines.push(` ${formatParameterType(definition)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
"Usage:",
|
|
96
|
+
` launchr ${commandName}${usageTokens.length > 0 ? ` ${usageTokens.join(" ")}` : ""}`,
|
|
97
|
+
"",
|
|
98
|
+
"Options:",
|
|
99
|
+
...(optionLines.length > 0 ? optionLines : [" (No parameters defined)"]),
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { PARAMETER_TYPES } from "../constants.mjs";
|
|
2
|
+
import { ValidationError } from "../utils/errors.mjs";
|
|
3
|
+
|
|
4
|
+
function normalizeFlag(rawFlag) {
|
|
5
|
+
const flag = rawFlag.replace(/^-+/, "").trim();
|
|
6
|
+
if (!/^[a-zA-Z]$/.test(flag)) {
|
|
7
|
+
throw new ValidationError('Flag must be a single alphabetic character (example: "q" for "-q").');
|
|
8
|
+
}
|
|
9
|
+
return flag;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseBooleanStrict(rawValue, fieldName) {
|
|
13
|
+
const normalized = rawValue.trim().toLowerCase();
|
|
14
|
+
if (["true", "yes", "y", "1"].includes(normalized)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (["false", "no", "n", "0"].includes(normalized)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
throw new ValidationError(`${fieldName} must be true/false.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseIntegerStrict(rawValue, fieldName) {
|
|
24
|
+
if (!/^-?\d+$/.test(rawValue.trim())) {
|
|
25
|
+
throw new ValidationError(`${fieldName} must be an integer.`);
|
|
26
|
+
}
|
|
27
|
+
return Number.parseInt(rawValue.trim(), 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function askNonEmpty(prompter, question) {
|
|
31
|
+
while (true) {
|
|
32
|
+
const answer = await prompter.ask(question);
|
|
33
|
+
if (answer.length > 0) {
|
|
34
|
+
return answer;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function askParameterType(prompter) {
|
|
40
|
+
while (true) {
|
|
41
|
+
const answer = (await askNonEmpty(prompter, "What type? (string/integer/boolean/single-choice-list): ")).toLowerCase();
|
|
42
|
+
if (PARAMETER_TYPES.includes(answer)) {
|
|
43
|
+
return answer;
|
|
44
|
+
}
|
|
45
|
+
prompter.write(`Invalid type. Allowed values: ${PARAMETER_TYPES.join(", ")}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function askRequired(prompter) {
|
|
50
|
+
while (true) {
|
|
51
|
+
const answer = (await askNonEmpty(prompter, "Is it required? (true/false): ")).toLowerCase();
|
|
52
|
+
if (answer === "true" || answer === "false") {
|
|
53
|
+
return answer === "true";
|
|
54
|
+
}
|
|
55
|
+
prompter.write("Please answer true or false.\n");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseDefaultValue(rawDefault, type, values) {
|
|
60
|
+
const trimmed = rawDefault.trim();
|
|
61
|
+
if (trimmed === "") {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
switch (type) {
|
|
66
|
+
case "string":
|
|
67
|
+
return trimmed;
|
|
68
|
+
case "integer":
|
|
69
|
+
return parseIntegerStrict(trimmed, "Default value");
|
|
70
|
+
case "boolean":
|
|
71
|
+
return parseBooleanStrict(trimmed, "Default value");
|
|
72
|
+
case "single-choice-list":
|
|
73
|
+
if (!values.includes(trimmed)) {
|
|
74
|
+
throw new ValidationError(`Default value must be one of: ${values.join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
return trimmed;
|
|
77
|
+
default:
|
|
78
|
+
return trimmed;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function collectParameterDefinition(prompter, parameterKeys, flagsInUse) {
|
|
83
|
+
const parameterKey = await askNonEmpty(prompter, "What parameter key do you want? (type done to finish): ");
|
|
84
|
+
if (parameterKey.toLowerCase() === "done") {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (parameterKeys.has(parameterKey)) {
|
|
88
|
+
throw new ValidationError(`Parameter "${parameterKey}" already exists for this command.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const type = await askParameterType(prompter);
|
|
92
|
+
const rawFlag = await askNonEmpty(prompter, "What flag should be used? (example: q): ");
|
|
93
|
+
const flag = normalizeFlag(rawFlag);
|
|
94
|
+
if (flagsInUse.has(flag)) {
|
|
95
|
+
throw new ValidationError(`Flag "-${flag}" is already in use for this command.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const required = await askRequired(prompter);
|
|
99
|
+
const rawDefault = await prompter.ask("Default value? (leave empty for none): ");
|
|
100
|
+
|
|
101
|
+
let values = undefined;
|
|
102
|
+
if (type === "single-choice-list") {
|
|
103
|
+
const rawValues = await askNonEmpty(prompter, "Enter allowed values (comma separated): ");
|
|
104
|
+
values = rawValues
|
|
105
|
+
.split(",")
|
|
106
|
+
.map((value) => value.trim())
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
|
|
109
|
+
if (values.length === 0) {
|
|
110
|
+
throw new ValidationError("Single-choice-list requires at least one allowed value.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const defaultValue = parseDefaultValue(rawDefault, type, values ?? []);
|
|
115
|
+
return {
|
|
116
|
+
parameterKey,
|
|
117
|
+
parameterDefinition: {
|
|
118
|
+
type,
|
|
119
|
+
flag,
|
|
120
|
+
required,
|
|
121
|
+
defaultValue,
|
|
122
|
+
...(values ? { values } : {}),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function askAddAnotherCommand(prompter) {
|
|
128
|
+
while (true) {
|
|
129
|
+
const answer = (await askNonEmpty(prompter, "Do you want to add another command? (yes/no): ")).toLowerCase();
|
|
130
|
+
if (answer === "yes" || answer === "y") {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (answer === "no" || answer === "n") {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
prompter.write("Please answer yes or no.\n");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function runInitFlow({ config, prompter, saveConfig }) {
|
|
141
|
+
const updatedConfig = { ...config };
|
|
142
|
+
|
|
143
|
+
while (true) {
|
|
144
|
+
const commandName = await askNonEmpty(
|
|
145
|
+
prompter,
|
|
146
|
+
"What command do you want to create? (example: grafana, google, or finish): ",
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (commandName.toLowerCase() === "finish") {
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (updatedConfig[commandName]) {
|
|
154
|
+
prompter.write(`Command "${commandName}" already exists.\n`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const description = await askNonEmpty(prompter, "What description do you want to add? ");
|
|
159
|
+
const url = await askNonEmpty(
|
|
160
|
+
prompter,
|
|
161
|
+
"What URL template do you want to use? (example: https://grafana.com/{environments}/{query}/{timeframe}) ",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const parameterKeys = new Set();
|
|
165
|
+
const flagsInUse = new Set();
|
|
166
|
+
const parameters = {};
|
|
167
|
+
|
|
168
|
+
while (true) {
|
|
169
|
+
try {
|
|
170
|
+
const parameter = await collectParameterDefinition(prompter, parameterKeys, flagsInUse);
|
|
171
|
+
if (parameter === null) {
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
parameterKeys.add(parameter.parameterKey);
|
|
176
|
+
flagsInUse.add(parameter.parameterDefinition.flag);
|
|
177
|
+
parameters[parameter.parameterKey] = parameter.parameterDefinition;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
prompter.write(`${error.message}\n`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
updatedConfig[commandName] = {
|
|
184
|
+
description,
|
|
185
|
+
url,
|
|
186
|
+
parameters,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const addAnother = await askAddAnotherCommand(prompter);
|
|
190
|
+
if (!addAnother) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await saveConfig(updatedConfig);
|
|
196
|
+
prompter.write("Configuration updated.\n");
|
|
197
|
+
return updatedConfig;
|
|
198
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function buildCommandList(config = {}) {
|
|
2
|
+
const entries = Object.entries(config);
|
|
3
|
+
if (entries.length === 0) {
|
|
4
|
+
return "No commands configured yet. Run `launchr init` to add commands.";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
return entries
|
|
8
|
+
.map(([name, definition]) => `${name} - ${definition.description}`)
|
|
9
|
+
.join("\n");
|
|
10
|
+
}
|