-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutils.ts
More file actions
382 lines (339 loc) · 10.5 KB
/
utils.ts
File metadata and controls
382 lines (339 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
/**
* Utility functions for CLI
*
* @module
*/
import { as, ensure, is, type Predicate } from "@core/unknownutil";
/**
* Result of extracting deno options from arguments
*/
export interface ExtractDenoOptionsResult {
/** Deno arguments with --deno- prefix removed (e.g., --config, --lock) */
denoArgs: string[];
/** Remaining arguments after extracting deno options */
remainingArgs: string[];
}
/**
* Extract --deno-* options and convert to deno command args
*
* Options starting with --deno- are extracted and converted to
* the corresponding deno option (e.g., --deno-config becomes --config).
*
* Options with `=` are passed as value options (e.g., `--deno-config=path` → `--config=path`).
* Options without `=` are passed as boolean flags (e.g., `--deno-reload` → `--reload`).
*
* @param args - Command-line arguments
* @returns Extracted deno args and remaining args
*
* @example
* ```ts
* const { denoArgs, remainingArgs } = extractDenoOptions([
* "--deno-config=custom/deno.json",
* "--selector", "foo",
* "--deno-lock=custom/deno.lock",
* "--deno-no-prompt",
* "--deno-reload",
* ]);
* // denoArgs: ["--config=custom/deno.json", "--lock=custom/deno.lock", "--no-prompt", "--reload"]
* // remainingArgs: ["--selector", "foo"]
* ```
*/
export function extractDenoOptions(args: string[]): ExtractDenoOptionsResult {
const denoArgs: string[] = [];
const remainingArgs: string[] = [];
for (const arg of args) {
if (arg.startsWith("--deno-")) {
// Convert --deno-xxx to --xxx (preserving =value if present)
const optionBody = arg.slice(7); // e.g., "config=path" or "reload"
const denoArg = "--" + optionBody;
denoArgs.push(denoArg);
} else {
remainingArgs.push(arg);
}
}
return { denoArgs, remainingArgs };
}
import { getLogger } from "@logtape/logtape";
import { JSONReporter, ListReporter } from "@probitas/reporter";
import type { Reporter } from "@probitas/runner";
import type { ReporterOptions } from "@probitas/reporter";
import { load } from "@std/dotenv";
import { exists } from "@std/fs";
import { resolve } from "@std/path";
const logger = getLogger(["probitas", "cli", "utils"]);
type DenoJson = {
version?: string;
};
const isDenoJson = is.ObjectOf({
version: as.Optional(is.String),
}) satisfies Predicate<DenoJson>;
type DenoLock = {
specifiers?: Record<string, string>;
};
const isDenoLock = is.ObjectOf({
specifiers: as.Optional(is.RecordOf(is.String, is.String)),
}) satisfies Predicate<DenoLock>;
/**
* Version information including CLI and dependency versions
*/
export type VersionInfo = {
/** CLI version */
readonly version: string;
/** @probitas package versions (sorted by name) */
readonly packages: ReadonlyArray<{ name: string; version: string }>;
};
const reporterMap: Record<string, (opts?: ReporterOptions) => Reporter> = {
list: (opts) => new ListReporter(opts),
json: (opts) => new JSONReporter(opts),
};
/**
* Resolve reporter by name
*
* @param reporter - Reporter name (list/json) or undefined for default
* @param options - Optional reporter options
* @returns Reporter instance
*/
export function resolveReporter(
reporter: string | undefined,
options?: ReporterOptions,
): Reporter {
const reporterName = reporter ?? "list";
logger.debug("Resolving reporter", {
reporterName,
options,
});
if (!reporter) {
logger.debug("Using default reporter", { reporter: "list" });
return new ListReporter(options);
}
const fn = reporterMap[reporter];
if (!fn) {
throw new Error(`Unknown reporter: ${reporter}`);
}
logger.debug("Reporter resolved", { reporter });
return fn(options);
}
/**
* Parse positive integer option
*
* @param value - Value to parse
* @param name - Option name for error messages
* @returns Parsed integer or undefined if not set
*/
export function parsePositiveInteger(
value: string | number | undefined,
name: string = "value",
): number | undefined {
if (value === undefined) {
return undefined;
}
const num = typeof value === "number" ? value : Number(value);
if (!Number.isInteger(num) || num < 1) {
throw new Error(`${name} must be a positive integer`);
}
return num;
}
/**
* Parse timeout string to seconds
*
* Supports formats: "30s", "10m", "1h", or plain numbers (treated as seconds)
* Special case: "0", "0s", "0m", "0h" returns undefined (no timeout)
*
* @param value - Timeout value to parse (e.g., "30s", "10m", "1h")
* @returns Timeout in seconds, or undefined if value is "0" or equivalent
* @throws Error if format is invalid
*
* @example
* ```ts
* parseTimeout("30s") // 30
* parseTimeout("10m") // 600
* parseTimeout("1h") // 3600
* parseTimeout("0") // undefined (no timeout)
* parseTimeout("0s") // undefined (no timeout)
* ```
*/
export function parseTimeout(
value: string,
): number | undefined {
const match = value.match(/^(\d+(?:\.\d+)?)(s|m|h)?$/i);
if (!match) {
throw new Error(
`Invalid timeout format: "${value}". Expected format: "30s", "10m", "1h", or a number`,
);
}
const num = parseFloat(match[1]);
const unit = (match[2] || "s").toLowerCase();
let seconds: number;
switch (unit) {
case "s":
seconds = num;
break;
case "m":
seconds = num * 60;
break;
case "h":
seconds = num * 3600;
break;
default:
// This should never happen due to regex validation
throw new Error(`Invalid timeout unit: "${unit}"`);
}
// Return undefined for zero timeout (means no timeout)
if (seconds === 0) {
return undefined;
}
if (seconds < 0 || !Number.isFinite(seconds)) {
throw new Error(`Timeout must be a non-negative number`);
}
return seconds;
}
/**
* Read asset file from assets directory
*
* Uses import.meta.dirname for compatibility with deno compile.
* The --include flag must be used when compiling to embed assets.
*
* @param path - Asset path relative to assets/ (e.g., "usage.txt", "templates/deno.json")
* @returns Asset content
*/
export async function readAsset(path: string): Promise<string> {
const url = new URL(`../../assets/${path}`, import.meta.url);
const resp = await fetch(url);
return await resp.text();
}
/**
* Get version from deno.json
*
* Uses import.meta.dirname for compatibility with deno compile.
* The --include flag must be used when compiling to embed deno.json.
*
* @returns Version string, or undefined if not available
*/
export async function getVersion(): Promise<string | undefined> {
try {
const url = new URL("../../deno.json", import.meta.url);
const resp = await fetch(url);
const content = await resp.text();
const denoJson = ensure(JSON.parse(content), isDenoJson);
return denoJson.version;
} catch (err: unknown) {
logger.debug("Failed to read version from deno.json", {
err,
});
return undefined;
}
}
/**
* Get version information including CLI and @probitas package versions
*
* Reads CLI version from deno.json and package versions from deno.lock.
* Package versions are extracted from the specifiers section of deno.lock.
*
* @returns Version information including CLI and package versions
*/
export async function getVersionInfo(): Promise<VersionInfo | undefined> {
try {
// Get CLI version from deno.json
const denoJsonUrl = new URL("../../deno.json", import.meta.url);
const denoJsonResp = await fetch(denoJsonUrl);
const denoJsonContent = await denoJsonResp.text();
const denoJson = ensure(JSON.parse(denoJsonContent), isDenoJson);
const version = denoJson.version ?? "unknown";
// Get package versions from deno.lock
const denoLockUrl = new URL("../../deno.lock", import.meta.url);
const denoLockResp = await fetch(denoLockUrl);
const denoLockContent = await denoLockResp.text();
const denoLock = ensure(JSON.parse(denoLockContent), isDenoLock);
// Extract @probitas package versions from specifiers
// Format: "jsr:@probitas/core@^0.2.0": "0.2.0"
const packages: { name: string; version: string }[] = [];
const seen = new Set<string>();
if (denoLock.specifiers) {
for (
const [specifier, resolvedVersion] of Object.entries(
denoLock.specifiers,
)
) {
// Match jsr:@probitas/package-name@version pattern
const match = specifier.match(/^jsr:(@probitas\/[^@]+)@/);
if (match && typeof resolvedVersion === "string") {
const name = match[1];
// Skip duplicates (same package with different version specifiers)
if (!seen.has(name)) {
seen.add(name);
packages.push({ name, version: resolvedVersion });
}
}
}
}
// Sort packages by name for consistent output
packages.sort((a, b) => a.name.localeCompare(b.name));
return { version, packages };
} catch (err: unknown) {
logger.debug("Failed to read version info", { err });
return undefined;
}
}
/**
* Load environment variables from a .env file
*
* @param cwd - Current working directory
* @param options - Environment loading options
* @returns void
*
* @example
* ```ts
* import { loadEnvironment } from "./utils.ts";
*
* const cwd = Deno.cwd();
*
* // Load default .env file
* await loadEnvironment(cwd);
*
* // Skip loading .env
* await loadEnvironment(cwd, { noEnv: true });
*
* // Load custom .env file
* await loadEnvironment(cwd, { envFile: ".env.test" });
* ```
*/
export async function loadEnvironment(
cwd: string,
options?: {
/** Skip loading .env file */
noEnv?: boolean;
/** Custom .env file path (relative to cwd or absolute) */
envFile?: string;
},
): Promise<void> {
const { noEnv = false, envFile } = options ?? {};
// Skip if --no-env is specified
if (noEnv) {
logger.debug("Environment loading disabled via --no-env");
return;
}
// Determine which file to load
const targetFile = envFile ?? ".env";
const targetPath = resolve(cwd, targetFile);
// Check if file exists
if (!(await exists(targetPath))) {
logger.debug("Environment file not found", { path: targetPath });
return;
}
// Load environment variables
try {
const env = await load({ envPath: targetPath, export: true });
logger.debug("Environment loaded", {
path: targetPath,
keys: Object.keys(env),
});
} catch (err: unknown) {
// Log error but don't fail - missing .env is acceptable
logger.debug("Failed to load environment file", {
path: targetPath,
error: err,
});
}
}
// Re-export configureLogging from shared template module
export { configureLogging } from "./_templates/logging.ts";