@evcraddock/slug-cli 0.2.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.
@@ -0,0 +1,17 @@
1
+ export interface SlugConfig {
2
+ apiBaseUrl?: string;
3
+ apiKey?: string;
4
+ apiKeyReference?: string;
5
+ }
6
+ export interface DisplayConfig {
7
+ apiBaseUrl?: string;
8
+ apiKeyConfigured: boolean;
9
+ apiKeyReference?: string;
10
+ }
11
+ export declare function createDefaultConfig(): SlugConfig;
12
+ export declare function getConfigPath(environment?: NodeJS.ProcessEnv): string;
13
+ export declare function readConfig(configPath: string): Promise<SlugConfig>;
14
+ export declare function writeConfig(configPath: string, config: SlugConfig): Promise<void>;
15
+ export declare function setConfigApiBaseUrl(config: SlugConfig, apiBaseUrl: string): SlugConfig;
16
+ export declare function setConfigApiKey(config: SlugConfig, apiKey: string): SlugConfig;
17
+ export declare function toDisplayConfig(config: SlugConfig): DisplayConfig;
package/dist/config.js ADDED
@@ -0,0 +1,125 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export function createDefaultConfig() {
5
+ return {};
6
+ }
7
+ export function getConfigPath(environment = process.env) {
8
+ const configHome = environment.SLUG_CONFIG_HOME ?? environment.XDG_CONFIG_HOME ?? join(homedir(), ".config");
9
+ return join(configHome, "slug", "config.yaml");
10
+ }
11
+ export async function readConfig(configPath) {
12
+ try {
13
+ const content = await readFile(configPath, "utf8");
14
+ return parseConfig(content);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
18
+ return createDefaultConfig();
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ export async function writeConfig(configPath, config) {
24
+ await mkdir(dirname(configPath), { recursive: true });
25
+ await writeFile(configPath, serializeConfig(config), "utf8");
26
+ }
27
+ export function setConfigApiBaseUrl(config, apiBaseUrl) {
28
+ return { ...config, apiBaseUrl };
29
+ }
30
+ export function setConfigApiKey(config, apiKey) {
31
+ const nextConfig = { ...config, apiKey };
32
+ delete nextConfig.apiKeyReference;
33
+ return nextConfig;
34
+ }
35
+ export function toDisplayConfig(config) {
36
+ return {
37
+ apiBaseUrl: config.apiBaseUrl,
38
+ apiKeyConfigured: config.apiKey !== undefined || config.apiKeyReference !== undefined,
39
+ apiKeyReference: config.apiKeyReference,
40
+ };
41
+ }
42
+ function parseConfig(content) {
43
+ const trimmed = content.trim();
44
+ if (trimmed === "" || trimmed === "{}") {
45
+ return createDefaultConfig();
46
+ }
47
+ if (trimmed.startsWith("{")) {
48
+ return normalizeParsedConfig(JSON.parse(trimmed));
49
+ }
50
+ const parsed = {};
51
+ for (const [index, rawLine] of content.split(/\r?\n/).entries()) {
52
+ const line = stripComment(rawLine).trim();
53
+ if (line === "" || line === "---") {
54
+ continue;
55
+ }
56
+ const match = /^(apiBaseUrl|apiKey|apiKeyReference):(?:\s*(.*))?$/.exec(line);
57
+ if (match === null) {
58
+ throw new Error(`Invalid slug config YAML at line ${index + 1}`);
59
+ }
60
+ const [, key, rawValue = ""] = match;
61
+ parsed[key] = parseScalar(rawValue.trim());
62
+ }
63
+ return normalizeParsedConfig(parsed);
64
+ }
65
+ function serializeConfig(config) {
66
+ const lines = [];
67
+ if (config.apiBaseUrl !== undefined) {
68
+ lines.push(`apiBaseUrl: ${formatScalar(config.apiBaseUrl)}`);
69
+ }
70
+ if (config.apiKey !== undefined) {
71
+ lines.push(`apiKey: ${formatScalar(config.apiKey)}`);
72
+ }
73
+ if (config.apiKeyReference !== undefined) {
74
+ lines.push(`apiKeyReference: ${formatScalar(config.apiKeyReference)}`);
75
+ }
76
+ return lines.length === 0 ? "{}\n" : `${lines.join("\n")}\n`;
77
+ }
78
+ function normalizeParsedConfig(config) {
79
+ const normalized = {};
80
+ if (typeof config.apiBaseUrl === "string") {
81
+ normalized.apiBaseUrl = config.apiBaseUrl;
82
+ }
83
+ if (typeof config.apiKey === "string") {
84
+ normalized.apiKey = config.apiKey;
85
+ }
86
+ if (typeof config.apiKeyReference === "string") {
87
+ normalized.apiKeyReference = config.apiKeyReference;
88
+ }
89
+ return normalized;
90
+ }
91
+ function stripComment(line) {
92
+ let quote;
93
+ for (let index = 0; index < line.length; index += 1) {
94
+ const char = line[index];
95
+ if (quote !== undefined) {
96
+ if (char === quote) {
97
+ quote = undefined;
98
+ }
99
+ continue;
100
+ }
101
+ if (char === '"' || char === "'") {
102
+ quote = char;
103
+ continue;
104
+ }
105
+ if (char === "#") {
106
+ return line.slice(0, index);
107
+ }
108
+ }
109
+ return line;
110
+ }
111
+ function parseScalar(value) {
112
+ if (value.startsWith('"') && value.endsWith('"')) {
113
+ return JSON.parse(value);
114
+ }
115
+ if (value.startsWith("'") && value.endsWith("'")) {
116
+ return value.slice(1, -1).replaceAll("''", "'");
117
+ }
118
+ return value;
119
+ }
120
+ function formatScalar(value) {
121
+ if (/^[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+$/.test(value)) {
122
+ return value;
123
+ }
124
+ return JSON.stringify(value);
125
+ }
@@ -0,0 +1,13 @@
1
+ export declare const ExitCode: {
2
+ readonly Ok: 0;
3
+ readonly InvalidUsage: 2;
4
+ readonly NetworkError: 10;
5
+ readonly AuthenticationError: 11;
6
+ readonly ApiError: 12;
7
+ };
8
+ export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
9
+ export declare class CliError extends Error {
10
+ readonly exitCode: ExitCode;
11
+ constructor(message: string, exitCode: ExitCode);
12
+ }
13
+ export declare function createInvalidUsageError(message: string): CliError;
package/dist/errors.js ADDED
@@ -0,0 +1,18 @@
1
+ export const ExitCode = {
2
+ Ok: 0,
3
+ InvalidUsage: 2,
4
+ NetworkError: 10,
5
+ AuthenticationError: 11,
6
+ ApiError: 12,
7
+ };
8
+ export class CliError extends Error {
9
+ exitCode;
10
+ constructor(message, exitCode) {
11
+ super(message);
12
+ this.name = "CliError";
13
+ this.exitCode = exitCode;
14
+ }
15
+ }
16
+ export function createInvalidUsageError(message) {
17
+ return new CliError(message, ExitCode.InvalidUsage);
18
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ export interface HttpClientOptions {
2
+ apiBaseUrl: string;
3
+ apiKey?: string;
4
+ fetchImpl?: typeof fetch;
5
+ }
6
+ export interface HttpRequestOptions {
7
+ method?: string;
8
+ path: string;
9
+ body?: unknown;
10
+ }
11
+ export interface ApiErrorBody {
12
+ error?: {
13
+ code?: string;
14
+ message?: string;
15
+ operation?: string;
16
+ };
17
+ }
18
+ export declare class SlugHttpClient {
19
+ private readonly apiBaseUrl;
20
+ private readonly apiKey;
21
+ private readonly fetchImpl;
22
+ constructor(options: HttpClientOptions);
23
+ requestJson<TResponse>(options: HttpRequestOptions): Promise<TResponse>;
24
+ requestVoid(options: HttpRequestOptions): Promise<void>;
25
+ private request;
26
+ private createHeaders;
27
+ }
package/dist/http.js ADDED
@@ -0,0 +1,71 @@
1
+ import { CliError, ExitCode } from "./errors.js";
2
+ export class SlugHttpClient {
3
+ apiBaseUrl;
4
+ apiKey;
5
+ fetchImpl;
6
+ constructor(options) {
7
+ this.apiBaseUrl = options.apiBaseUrl.replace(/\/$/, "");
8
+ this.apiKey = options.apiKey;
9
+ this.fetchImpl = options.fetchImpl ?? fetch;
10
+ }
11
+ async requestJson(options) {
12
+ const response = await this.request(options);
13
+ return (await response.json());
14
+ }
15
+ async requestVoid(options) {
16
+ await this.request(options);
17
+ }
18
+ async request(options) {
19
+ const url = `${this.apiBaseUrl}${options.path}`;
20
+ try {
21
+ const response = await this.fetchImpl(url, {
22
+ method: options.method ?? "GET",
23
+ headers: this.createHeaders(options.body),
24
+ body: createRequestBody(options.body),
25
+ });
26
+ if (response.status === 401 || response.status === 403) {
27
+ throw new CliError(await readApiErrorMessage(response, "Authentication failed"), ExitCode.AuthenticationError);
28
+ }
29
+ if (!response.ok) {
30
+ throw new CliError(await readApiErrorMessage(response, `API request failed with status ${response.status}`), ExitCode.ApiError);
31
+ }
32
+ return response;
33
+ }
34
+ catch (error) {
35
+ if (error instanceof CliError) {
36
+ throw error;
37
+ }
38
+ throw new CliError("Network request failed", ExitCode.NetworkError);
39
+ }
40
+ }
41
+ createHeaders(body) {
42
+ const headers = {
43
+ accept: "application/json",
44
+ };
45
+ if (body !== undefined && !(body instanceof FormData)) {
46
+ headers["content-type"] = "application/json";
47
+ }
48
+ if (this.apiKey !== undefined) {
49
+ headers.authorization = `Bearer ${this.apiKey}`;
50
+ }
51
+ return headers;
52
+ }
53
+ }
54
+ function createRequestBody(body) {
55
+ if (body === undefined) {
56
+ return undefined;
57
+ }
58
+ if (body instanceof FormData) {
59
+ return body;
60
+ }
61
+ return JSON.stringify(body);
62
+ }
63
+ async function readApiErrorMessage(response, fallback) {
64
+ try {
65
+ const body = (await response.json());
66
+ return body.error?.message ?? fallback;
67
+ }
68
+ catch {
69
+ return fallback;
70
+ }
71
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): Promise<number>;
package/dist/index.js ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname, join } from "node:path";
7
+ import { getConfigPath } from "./config.js";
8
+ import { runCommand } from "./commands.js";
9
+ import { CliError, ExitCode, createInvalidUsageError } from "./errors.js";
10
+ import { createConsoleWriter } from "./output.js";
11
+ export async function main(args = process.argv.slice(2)) {
12
+ const writer = createConsoleWriter();
13
+ try {
14
+ const parsedArgs = parseGlobalOptions(args);
15
+ const result = await runCommand({
16
+ args: parsedArgs.commandArgs,
17
+ configPath: parsedArgs.configPath ?? getConfigPath(),
18
+ packageVersion: readPackageVersion(),
19
+ writer,
20
+ openUrl,
21
+ prompt: promptUser,
22
+ });
23
+ return result.exitCode;
24
+ }
25
+ catch (error) {
26
+ if (error instanceof CliError) {
27
+ writer.stderr(error.message);
28
+ return error.exitCode;
29
+ }
30
+ writer.stderr("Unexpected error");
31
+ return ExitCode.ApiError;
32
+ }
33
+ }
34
+ async function openUrl(url) {
35
+ const command = getOpenCommand(url);
36
+ const child = spawn(command.command, command.args, {
37
+ stdio: "ignore",
38
+ });
39
+ await new Promise((resolve, reject) => {
40
+ child.once("error", reject);
41
+ child.once("close", (code) => {
42
+ if (code === 0) {
43
+ resolve();
44
+ }
45
+ else {
46
+ reject(new Error(`Browser opener exited with code ${code ?? "unknown"}`));
47
+ }
48
+ });
49
+ });
50
+ }
51
+ async function promptUser(message) {
52
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
53
+ try {
54
+ return await readline.question(message);
55
+ }
56
+ finally {
57
+ readline.close();
58
+ }
59
+ }
60
+ function parseGlobalOptions(args) {
61
+ const commandArgs = [];
62
+ let configPath;
63
+ for (let index = 0; index < args.length; index += 1) {
64
+ const arg = args[index];
65
+ if (arg === "--config") {
66
+ const value = args[index + 1];
67
+ if (value === undefined || value.startsWith("--")) {
68
+ throw createInvalidUsageError("Usage: slug --config <file> <command>");
69
+ }
70
+ configPath = value;
71
+ index += 1;
72
+ continue;
73
+ }
74
+ if (arg?.startsWith("--config=")) {
75
+ const value = arg.slice("--config=".length);
76
+ if (value === "") {
77
+ throw createInvalidUsageError("Usage: slug --config <file> <command>");
78
+ }
79
+ configPath = value;
80
+ continue;
81
+ }
82
+ commandArgs.push(arg);
83
+ }
84
+ return { commandArgs, configPath };
85
+ }
86
+ function getOpenCommand(url) {
87
+ if (process.platform === "darwin") {
88
+ return { command: "open", args: [url] };
89
+ }
90
+ if (process.platform === "win32") {
91
+ return { command: "cmd", args: ["/c", "start", url] };
92
+ }
93
+ return { command: "xdg-open", args: [url] };
94
+ }
95
+ function readPackageVersion() {
96
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
97
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
98
+ return packageJson.version;
99
+ }
100
+ if (process.argv[1] !== undefined &&
101
+ realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
102
+ process.exitCode = await main();
103
+ }
@@ -0,0 +1,10 @@
1
+ export interface OutputWriter {
2
+ stdout(message: string): void;
3
+ stderr(message: string): void;
4
+ }
5
+ export interface JsonOutput {
6
+ json: boolean;
7
+ }
8
+ export declare function createConsoleWriter(): OutputWriter;
9
+ export declare function writeJson(writer: OutputWriter, value: unknown): void;
10
+ export declare function redactSecret(value: string | undefined): string | undefined;
package/dist/output.js ADDED
@@ -0,0 +1,15 @@
1
+ export function createConsoleWriter() {
2
+ return {
3
+ stdout: (message) => console.log(message),
4
+ stderr: (message) => console.error(message),
5
+ };
6
+ }
7
+ export function writeJson(writer, value) {
8
+ writer.stdout(JSON.stringify(value));
9
+ }
10
+ export function redactSecret(value) {
11
+ if (value === undefined) {
12
+ return undefined;
13
+ }
14
+ return "[redacted]";
15
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@evcraddock/slug-cli",
3
+ "version": "0.2.0",
4
+ "description": "Command-line tool for Slugkit sites.",
5
+ "private": false,
6
+ "type": "module",
7
+ "bin": {
8
+ "slug": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "package.json"
14
+ ],
15
+ "engines": {
16
+ "node": ">=24"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "dev": "node --import tsx src/index.ts",
24
+ "typecheck": "tsc --noEmit -p tsconfig.json",
25
+ "test": "vitest run --config ../vitest.config.ts src",
26
+ "test:watch": "vitest --config ../vitest.config.ts src"
27
+ },
28
+ "dependencies": {
29
+ "tar": "^7.5.16"
30
+ }
31
+ }