@rikalabs/logpoint 0.0.2
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/README.md +104 -0
- package/SKILL.md +68 -0
- package/agents/debugger.md +13 -0
- package/docs/cli-compatibility.md +65 -0
- package/docs/testing-and-quality.md +40 -0
- package/package.json +54 -0
- package/src/analyze.ts +69 -0
- package/src/cleanup.ts +150 -0
- package/src/cli.ts +84 -0
- package/src/collector.ts +192 -0
- package/src/doctor.ts +63 -0
- package/src/inject.ts +168 -0
- package/src/lib/cli-args.ts +140 -0
- package/src/lib/errors.ts +108 -0
- package/src/lib/injection-engine.ts +329 -0
- package/src/lib/jsonl.ts +40 -0
- package/src/lib/language.ts +49 -0
- package/src/lib/render.ts +173 -0
- package/src/lib/schema.ts +249 -0
- package/src/lib/secrets.ts +4 -0
- package/src/lib/services.ts +107 -0
- package/src/lib/templates.ts +400 -0
- package/src/validate.ts +53 -0
package/src/collector.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { appendFile } from "node:fs/promises";
|
|
2
|
+
import { Duration, Effect } from "effect";
|
|
3
|
+
import { optionalNumberOption, optionalStringOption, parseArgs } from "./lib/cli-args.js";
|
|
4
|
+
import { CollectorTimeout, ManifestError, PortInUse, ValidationError } from "./lib/errors.js";
|
|
5
|
+
import { decodeCollectorConfigUnknown, decodeSnapshotUnknown, type CollectorConfig } from "./lib/schema.js";
|
|
6
|
+
import { AppLive, FileSystem, Logger } from "./lib/services.js";
|
|
7
|
+
|
|
8
|
+
const defaultPortFile = "/tmp/debug-logpoints.port";
|
|
9
|
+
const defaultPidFile = "/tmp/debug-logpoints.pid";
|
|
10
|
+
|
|
11
|
+
const isPortInUseError = (cause: unknown): boolean => {
|
|
12
|
+
const message = String(cause).toLowerCase();
|
|
13
|
+
return message.includes("address already in use") || message.includes("eaddrinuse");
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const corsHeaders = (origin: string): Record<string, string> => ({
|
|
17
|
+
"Access-Control-Allow-Origin": origin,
|
|
18
|
+
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
|
19
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const parseCollectorConfig = (argv: readonly string[]) =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const parsed = parseArgs(argv);
|
|
26
|
+
const port = yield* optionalNumberOption(parsed, "port");
|
|
27
|
+
const timeout = yield* optionalNumberOption(parsed, "timeout");
|
|
28
|
+
const output = yield* optionalStringOption(parsed, "output");
|
|
29
|
+
const corsOrigin = yield* optionalStringOption(parsed, "cors-origin");
|
|
30
|
+
|
|
31
|
+
return yield* decodeCollectorConfigUnknown({
|
|
32
|
+
port,
|
|
33
|
+
timeout,
|
|
34
|
+
output,
|
|
35
|
+
corsOrigin,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const startServer = (
|
|
40
|
+
port: number,
|
|
41
|
+
config: CollectorConfig,
|
|
42
|
+
hits: Map<string, number>,
|
|
43
|
+
): Effect.Effect<Bun.Server<unknown>, PortInUse | ManifestError, never> =>
|
|
44
|
+
Effect.try({
|
|
45
|
+
try: () =>
|
|
46
|
+
Bun.serve({
|
|
47
|
+
port,
|
|
48
|
+
fetch: async (request: Request): Promise<Response> => {
|
|
49
|
+
if (request.method === "OPTIONS") {
|
|
50
|
+
return new Response(null, { status: 204, headers: corsHeaders(config.corsOrigin) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (request.method !== "POST") {
|
|
54
|
+
return new Response(JSON.stringify({ ok: false, error: "Method not allowed" }), {
|
|
55
|
+
status: 405,
|
|
56
|
+
headers: corsHeaders(config.corsOrigin),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let body: unknown;
|
|
61
|
+
try {
|
|
62
|
+
body = (await request.json()) as unknown;
|
|
63
|
+
} catch {
|
|
64
|
+
return new Response(JSON.stringify({ ok: false, error: "Invalid JSON" }), {
|
|
65
|
+
status: 400,
|
|
66
|
+
headers: corsHeaders(config.corsOrigin),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const decoded = await Effect.runPromise(decodeSnapshotUnknown(body).pipe(Effect.either));
|
|
71
|
+
if (decoded._tag === "Left") {
|
|
72
|
+
return new Response(JSON.stringify({ ok: false, error: decoded.left.message }), {
|
|
73
|
+
status: 400,
|
|
74
|
+
headers: corsHeaders(config.corsOrigin),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const snapshot = decoded.right;
|
|
79
|
+
const count = (hits.get(snapshot.id) ?? 0) + 1;
|
|
80
|
+
hits.set(snapshot.id, count);
|
|
81
|
+
|
|
82
|
+
const maxHits = snapshot.maxHits;
|
|
83
|
+
if (maxHits !== undefined && count > maxHits) {
|
|
84
|
+
return new Response(JSON.stringify({ ok: true, skipped: true }), {
|
|
85
|
+
status: 202,
|
|
86
|
+
headers: corsHeaders(config.corsOrigin),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const enriched = {
|
|
91
|
+
...snapshot,
|
|
92
|
+
hit: snapshot.hit ?? count,
|
|
93
|
+
_receivedAt: new Date().toISOString(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const line = JSON.stringify(enriched);
|
|
98
|
+
await appendFile(config.output, `${line}\n`, "utf8");
|
|
99
|
+
console.log(line);
|
|
100
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: corsHeaders(config.corsOrigin),
|
|
103
|
+
});
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
return new Response(JSON.stringify({ ok: false, error: String(cause) }), {
|
|
106
|
+
status: 500,
|
|
107
|
+
headers: corsHeaders(config.corsOrigin),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
catch: (cause) => {
|
|
113
|
+
if (isPortInUseError(cause)) {
|
|
114
|
+
return new PortInUse({ port });
|
|
115
|
+
}
|
|
116
|
+
return new ManifestError({
|
|
117
|
+
message: `Failed to start collector on port ${port}`,
|
|
118
|
+
cause,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const runCollector = (
|
|
124
|
+
config: CollectorConfig,
|
|
125
|
+
): Effect.Effect<void, ManifestError | PortInUse | CollectorTimeout | ValidationError, FileSystem | Logger> =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
const logger = yield* Logger;
|
|
128
|
+
const fs = yield* FileSystem;
|
|
129
|
+
const hits = new Map<string, number>();
|
|
130
|
+
|
|
131
|
+
let server: Bun.Server<unknown> | undefined;
|
|
132
|
+
let boundPort = config.port;
|
|
133
|
+
|
|
134
|
+
for (let attempt = 0; attempt <= 10; attempt += 1) {
|
|
135
|
+
const port = config.port + attempt;
|
|
136
|
+
const started = yield* startServer(port, config, hits).pipe(Effect.either);
|
|
137
|
+
if (started._tag === "Right") {
|
|
138
|
+
server = started.right;
|
|
139
|
+
boundPort = port;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (started.left._tag !== "PortInUse" || attempt === 10) {
|
|
144
|
+
return yield* Effect.fail(started.left);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (server === undefined) {
|
|
149
|
+
return yield* Effect.fail(new ManifestError({ message: "Collector failed to start", cause: "no server" }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
yield* fs.writeFile(defaultPortFile, String(boundPort)).pipe(
|
|
153
|
+
Effect.mapError((error) => new ManifestError({ message: error.message, cause: error })),
|
|
154
|
+
);
|
|
155
|
+
yield* fs.writeFile(defaultPidFile, String(process.pid)).pipe(
|
|
156
|
+
Effect.mapError((error) => new ManifestError({ message: error.message, cause: error })),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
yield* logger.info(
|
|
160
|
+
`collector listening on http://localhost:${boundPort} output=${config.output} timeout=${config.timeout}s`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
yield* Effect.sleep(Duration.seconds(config.timeout));
|
|
164
|
+
server.stop(true);
|
|
165
|
+
yield* fs.removeFile(defaultPidFile).pipe(Effect.catchAll(() => Effect.void));
|
|
166
|
+
|
|
167
|
+
return yield* Effect.fail(new CollectorTimeout({ seconds: config.timeout }));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export const runCollectorFromArgs = (
|
|
171
|
+
argv: readonly string[],
|
|
172
|
+
): Effect.Effect<void, ManifestError | PortInUse | CollectorTimeout | ValidationError, FileSystem | Logger> =>
|
|
173
|
+
parseCollectorConfig(argv).pipe(Effect.flatMap((config) => runCollector(config)));
|
|
174
|
+
|
|
175
|
+
const execute = (argv: readonly string[]): Promise<void> =>
|
|
176
|
+
Effect.runPromise(
|
|
177
|
+
runCollectorFromArgs(argv).pipe(
|
|
178
|
+
Effect.provide(AppLive),
|
|
179
|
+
Effect.catchTag("CollectorTimeout", (error) =>
|
|
180
|
+
Effect.sync(() => {
|
|
181
|
+
console.error(error.message);
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (import.meta.main) {
|
|
188
|
+
execute(Bun.argv.slice(2)).catch((error) => {
|
|
189
|
+
console.error(error);
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
});
|
|
192
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { AppLive, FileSystem, Logger, RuntimeEnv } from "./lib/services.js";
|
|
4
|
+
|
|
5
|
+
const checkPort = (port: number): Effect.Effect<boolean, never, never> =>
|
|
6
|
+
Effect.async<boolean>((resume) => {
|
|
7
|
+
const server = createServer();
|
|
8
|
+
|
|
9
|
+
server.once("error", () => {
|
|
10
|
+
resume(Effect.succeed(false));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
server.listen(port, "127.0.0.1", () => {
|
|
14
|
+
server.close(() => {
|
|
15
|
+
resume(Effect.succeed(true));
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return Effect.sync(() => {
|
|
20
|
+
server.close();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const runDoctorFromArgs = (): Effect.Effect<void, never, FileSystem | Logger | RuntimeEnv> =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const fs = yield* FileSystem;
|
|
27
|
+
const logger = yield* Logger;
|
|
28
|
+
const env = yield* RuntimeEnv;
|
|
29
|
+
|
|
30
|
+
const probePath = `${env.tmpDir.replace(/\/$/, "")}/logpoint-doctor-${Date.now()}.tmp`;
|
|
31
|
+
|
|
32
|
+
const writable = yield* fs
|
|
33
|
+
.writeFile(probePath, "ok")
|
|
34
|
+
.pipe(
|
|
35
|
+
Effect.flatMap(() => fs.removeFile(probePath)),
|
|
36
|
+
Effect.map(() => true),
|
|
37
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const port9111Free = yield* checkPort(9111);
|
|
41
|
+
|
|
42
|
+
yield* logger.info(
|
|
43
|
+
JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
runtime: "bun",
|
|
46
|
+
bunVersion: Bun.version,
|
|
47
|
+
cwd: env.cwd,
|
|
48
|
+
tmpDir: env.tmpDir,
|
|
49
|
+
tmpWritable: writable,
|
|
50
|
+
port9111Available: port9111Free,
|
|
51
|
+
},
|
|
52
|
+
null,
|
|
53
|
+
2,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (import.meta.main) {
|
|
59
|
+
Effect.runPromise(runDoctorFromArgs().pipe(Effect.provide(AppLive))).catch((error) => {
|
|
60
|
+
console.error(error);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
});
|
|
63
|
+
}
|
package/src/inject.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import {
|
|
4
|
+
optionalBooleanOption,
|
|
5
|
+
optionalStringOption,
|
|
6
|
+
parseArgs,
|
|
7
|
+
requireStringOption,
|
|
8
|
+
} from "./lib/cli-args.js";
|
|
9
|
+
import {
|
|
10
|
+
InjectionError,
|
|
11
|
+
ManifestError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
type FileNotFound,
|
|
14
|
+
type FileReadError,
|
|
15
|
+
type FileWriteError,
|
|
16
|
+
type ParseError,
|
|
17
|
+
} from "./lib/errors.js";
|
|
18
|
+
import { injectContent } from "./lib/injection-engine.js";
|
|
19
|
+
import { isLanguage } from "./lib/language.js";
|
|
20
|
+
import {
|
|
21
|
+
decodeInjectConfigUnknown,
|
|
22
|
+
decodeManifestUnknown,
|
|
23
|
+
parseJsonString,
|
|
24
|
+
type InjectConfig,
|
|
25
|
+
type LogpointDef,
|
|
26
|
+
type Manifest,
|
|
27
|
+
} from "./lib/schema.js";
|
|
28
|
+
import { AppLive, FileSystem, Logger } from "./lib/services.js";
|
|
29
|
+
|
|
30
|
+
const parseInjectConfig = (argv: readonly string[]): Effect.Effect<InjectConfig, ManifestError | ValidationError, never> =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const parsed = parseArgs(argv);
|
|
33
|
+
const manifestFromArg = parsed.positionals[0];
|
|
34
|
+
const manifestFromOption = yield* optionalStringOption(parsed, "manifest");
|
|
35
|
+
const manifest = manifestFromOption ?? manifestFromArg;
|
|
36
|
+
|
|
37
|
+
if (manifest === undefined) {
|
|
38
|
+
return yield* requireStringOption(parsed, "manifest").pipe(
|
|
39
|
+
Effect.mapError((error) => new ManifestError({ message: error.message, cause: error })),
|
|
40
|
+
Effect.as(undefined as never),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const projectRoot = yield* optionalStringOption(parsed, "project-root");
|
|
45
|
+
const dryRun = yield* optionalBooleanOption(parsed, "dry-run");
|
|
46
|
+
const languageRaw = yield* optionalStringOption(parsed, "language");
|
|
47
|
+
|
|
48
|
+
if (languageRaw !== undefined && !isLanguage(languageRaw)) {
|
|
49
|
+
return yield* Effect.fail(
|
|
50
|
+
new ValidationError({
|
|
51
|
+
message: `Unsupported language: ${languageRaw}`,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return yield* decodeInjectConfigUnknown({
|
|
57
|
+
manifest,
|
|
58
|
+
projectRoot,
|
|
59
|
+
dryRun,
|
|
60
|
+
language: languageRaw,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const groupByFile = (
|
|
65
|
+
manifest: Manifest,
|
|
66
|
+
root: string,
|
|
67
|
+
): ReadonlyMap<string, readonly LogpointDef[]> => {
|
|
68
|
+
const grouped = new Map<string, LogpointDef[]>();
|
|
69
|
+
for (const logpoint of manifest.logpoints) {
|
|
70
|
+
const absolutePath = resolve(root, logpoint.file);
|
|
71
|
+
const current = grouped.get(absolutePath) ?? [];
|
|
72
|
+
current.push(logpoint);
|
|
73
|
+
grouped.set(absolutePath, current);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return grouped;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type InjectRunError =
|
|
80
|
+
| ManifestError
|
|
81
|
+
| ValidationError
|
|
82
|
+
| InjectionError
|
|
83
|
+
| FileNotFound
|
|
84
|
+
| FileReadError
|
|
85
|
+
| FileWriteError
|
|
86
|
+
| ParseError;
|
|
87
|
+
|
|
88
|
+
export const runInject = (
|
|
89
|
+
config: InjectConfig,
|
|
90
|
+
): Effect.Effect<void, InjectRunError, FileSystem | Logger> =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const fs = yield* FileSystem;
|
|
93
|
+
const logger = yield* Logger;
|
|
94
|
+
|
|
95
|
+
const manifestContent = yield* fs.readFile(config.manifest);
|
|
96
|
+
const manifestJson = yield* parseJsonString(manifestContent);
|
|
97
|
+
const manifest = yield* decodeManifestUnknown(manifestJson);
|
|
98
|
+
|
|
99
|
+
const root = resolve(config.projectRoot ?? manifest.projectRoot);
|
|
100
|
+
const grouped = groupByFile(manifest, root);
|
|
101
|
+
|
|
102
|
+
const entries = [...grouped.entries()];
|
|
103
|
+
|
|
104
|
+
const results = yield* Effect.forEach(
|
|
105
|
+
entries,
|
|
106
|
+
([filePath, defs]) =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const original = yield* fs.readFile(filePath);
|
|
109
|
+
|
|
110
|
+
const injected = yield* injectContent(
|
|
111
|
+
original,
|
|
112
|
+
defs,
|
|
113
|
+
filePath,
|
|
114
|
+
manifest.port,
|
|
115
|
+
config.language ?? manifest.language,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
for (const blocked of injected.blocked) {
|
|
119
|
+
yield* logger.warn(blocked.message);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!config.dryRun && injected.inserted > 0 && original !== injected.content) {
|
|
123
|
+
yield* fs.writeFile(filePath, injected.content);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
filePath,
|
|
128
|
+
inserted: injected.inserted,
|
|
129
|
+
blocked: injected.blocked.length,
|
|
130
|
+
dryRun: config.dryRun,
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
133
|
+
{ concurrency: 8 },
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const summary = {
|
|
137
|
+
files: results.length,
|
|
138
|
+
inserted: results.reduce((total, item) => total + item.inserted, 0),
|
|
139
|
+
blocked: results.reduce((total, item) => total + item.blocked, 0),
|
|
140
|
+
dryRun: config.dryRun,
|
|
141
|
+
manifest: config.manifest,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
yield* logger.json(summary);
|
|
145
|
+
|
|
146
|
+
if (config.dryRun) {
|
|
147
|
+
for (const item of results) {
|
|
148
|
+
if (item.inserted > 0) {
|
|
149
|
+
yield* logger.info(`dry-run would inject ${item.inserted} logpoints in ${item.filePath}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export const runInjectFromArgs = (
|
|
156
|
+
argv: readonly string[],
|
|
157
|
+
): Effect.Effect<void, InjectRunError, FileSystem | Logger> =>
|
|
158
|
+
parseInjectConfig(argv).pipe(Effect.flatMap((config) => runInject(config)));
|
|
159
|
+
|
|
160
|
+
const execute = (argv: readonly string[]): Promise<void> =>
|
|
161
|
+
Effect.runPromise(runInjectFromArgs(argv).pipe(Effect.provide(AppLive)));
|
|
162
|
+
|
|
163
|
+
if (import.meta.main) {
|
|
164
|
+
execute(Bun.argv.slice(2)).catch((error) => {
|
|
165
|
+
console.error(error);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ValidationError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export type ParsedArgs = {
|
|
5
|
+
readonly positionals: readonly string[];
|
|
6
|
+
readonly options: Readonly<Record<string, string | boolean>>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const parseArgs = (argv: readonly string[]): ParsedArgs => {
|
|
10
|
+
const positionals: string[] = [];
|
|
11
|
+
const options: Record<string, string | boolean> = {};
|
|
12
|
+
|
|
13
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
14
|
+
const token = argv[index];
|
|
15
|
+
if (token === undefined) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!token.startsWith("-")) {
|
|
20
|
+
positionals.push(token);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (token.startsWith("--")) {
|
|
25
|
+
const raw = token.slice(2);
|
|
26
|
+
if (raw.length === 0) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const equalsIndex = raw.indexOf("=");
|
|
31
|
+
if (equalsIndex >= 0) {
|
|
32
|
+
const key = raw.slice(0, equalsIndex);
|
|
33
|
+
const value = raw.slice(equalsIndex + 1);
|
|
34
|
+
options[key] = value;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const next = argv[index + 1];
|
|
39
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
40
|
+
options[raw] = next;
|
|
41
|
+
index += 1;
|
|
42
|
+
} else {
|
|
43
|
+
options[raw] = true;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const shortFlags = token.slice(1);
|
|
49
|
+
if (shortFlags.length === 0) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const flag of shortFlags) {
|
|
54
|
+
options[flag] = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { positionals, options };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getOption = (parsed: ParsedArgs, key: string): string | boolean | undefined => parsed.options[key];
|
|
62
|
+
|
|
63
|
+
export const requireStringOption = (
|
|
64
|
+
parsed: ParsedArgs,
|
|
65
|
+
key: string,
|
|
66
|
+
): Effect.Effect<string, ValidationError, never> => {
|
|
67
|
+
const value = getOption(parsed, key);
|
|
68
|
+
if (typeof value === "string" && value.length > 0) {
|
|
69
|
+
return Effect.succeed(value);
|
|
70
|
+
}
|
|
71
|
+
return Effect.fail(new ValidationError({ message: `Missing required option --${key}` }));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const optionalStringOption = (
|
|
75
|
+
parsed: ParsedArgs,
|
|
76
|
+
key: string,
|
|
77
|
+
): Effect.Effect<string | undefined, never, never> => {
|
|
78
|
+
const value = getOption(parsed, key);
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
return Effect.succeed(value);
|
|
81
|
+
}
|
|
82
|
+
return Effect.succeed(undefined);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const optionalBooleanOption = (
|
|
86
|
+
parsed: ParsedArgs,
|
|
87
|
+
key: string,
|
|
88
|
+
): Effect.Effect<boolean | undefined, never, never> => {
|
|
89
|
+
const value = getOption(parsed, key);
|
|
90
|
+
if (typeof value === "boolean") {
|
|
91
|
+
return Effect.succeed(value);
|
|
92
|
+
}
|
|
93
|
+
if (typeof value === "string") {
|
|
94
|
+
if (value === "true") {
|
|
95
|
+
return Effect.succeed(true);
|
|
96
|
+
}
|
|
97
|
+
if (value === "false") {
|
|
98
|
+
return Effect.succeed(false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return Effect.succeed(undefined);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const optionalNumberOption = (
|
|
105
|
+
parsed: ParsedArgs,
|
|
106
|
+
key: string,
|
|
107
|
+
): Effect.Effect<number | undefined, ValidationError, never> => {
|
|
108
|
+
const value = getOption(parsed, key);
|
|
109
|
+
if (value === undefined) {
|
|
110
|
+
return Effect.succeed(undefined);
|
|
111
|
+
}
|
|
112
|
+
if (typeof value === "boolean") {
|
|
113
|
+
return Effect.fail(new ValidationError({ message: `Option --${key} requires a number` }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parsedValue = Number(value);
|
|
117
|
+
if (Number.isNaN(parsedValue)) {
|
|
118
|
+
return Effect.fail(new ValidationError({ message: `Option --${key} is not a valid number` }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Effect.succeed(parsedValue);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const optionalCsvOption = (
|
|
125
|
+
parsed: ParsedArgs,
|
|
126
|
+
key: string,
|
|
127
|
+
): Effect.Effect<readonly string[] | undefined, never, never> => {
|
|
128
|
+
const value = getOption(parsed, key);
|
|
129
|
+
if (typeof value !== "string") {
|
|
130
|
+
return Effect.succeed(undefined);
|
|
131
|
+
}
|
|
132
|
+
const items = value
|
|
133
|
+
.split(",")
|
|
134
|
+
.map((item) => item.trim())
|
|
135
|
+
.filter((item) => item.length > 0);
|
|
136
|
+
if (items.length === 0) {
|
|
137
|
+
return Effect.succeed(undefined);
|
|
138
|
+
}
|
|
139
|
+
return Effect.succeed(items);
|
|
140
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
export class FileNotFound extends Data.TaggedError("FileNotFound")<{
|
|
4
|
+
readonly path: string;
|
|
5
|
+
}> {
|
|
6
|
+
override get message(): string {
|
|
7
|
+
return `File not found: ${this.path}`;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class FileReadError extends Data.TaggedError("FileReadError")<{
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly cause: unknown;
|
|
14
|
+
}> {
|
|
15
|
+
override get message(): string {
|
|
16
|
+
return `Failed to read file ${this.path}: ${String(this.cause)}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FileWriteError extends Data.TaggedError("FileWriteError")<{
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly cause: unknown;
|
|
23
|
+
}> {
|
|
24
|
+
override get message(): string {
|
|
25
|
+
return `Failed to write file ${this.path}: ${String(this.cause)}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ParseError extends Data.TaggedError("ParseError")<{
|
|
30
|
+
readonly input: string;
|
|
31
|
+
readonly cause: unknown;
|
|
32
|
+
}> {
|
|
33
|
+
override get message(): string {
|
|
34
|
+
return `Failed to parse input: ${this.input} (${String(this.cause)})`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ManifestError extends Data.TaggedError("ManifestError")<{
|
|
39
|
+
readonly message: string;
|
|
40
|
+
readonly cause: unknown;
|
|
41
|
+
}> {}
|
|
42
|
+
|
|
43
|
+
export class ValidationError extends Data.TaggedError("ValidationError")<{
|
|
44
|
+
readonly message: string;
|
|
45
|
+
}> {}
|
|
46
|
+
|
|
47
|
+
export class PortInUse extends Data.TaggedError("PortInUse")<{
|
|
48
|
+
readonly port: number;
|
|
49
|
+
}> {
|
|
50
|
+
override get message(): string {
|
|
51
|
+
return `Port is in use: ${this.port}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class InjectionError extends Data.TaggedError("InjectionError")<{
|
|
56
|
+
readonly logpointId: string;
|
|
57
|
+
readonly file: string;
|
|
58
|
+
readonly line: number;
|
|
59
|
+
readonly reason: string;
|
|
60
|
+
}> {
|
|
61
|
+
override get message(): string {
|
|
62
|
+
return `Injection failed for ${this.logpointId} at ${this.file}:${this.line} - ${this.reason}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class CleanupError extends Data.TaggedError("CleanupError")<{
|
|
67
|
+
readonly file: string;
|
|
68
|
+
readonly reason: string;
|
|
69
|
+
}> {
|
|
70
|
+
override get message(): string {
|
|
71
|
+
return `Cleanup failed for ${this.file}: ${this.reason}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class SecretVarBlocked extends Data.TaggedError("SecretVarBlocked")<{
|
|
76
|
+
readonly logpointId: string;
|
|
77
|
+
readonly variable: string;
|
|
78
|
+
}> {
|
|
79
|
+
override get message(): string {
|
|
80
|
+
return `Blocked secret variable ${this.variable} in ${this.logpointId}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class CollectorTimeout extends Data.TaggedError("CollectorTimeout")<{
|
|
85
|
+
readonly seconds: number;
|
|
86
|
+
}> {
|
|
87
|
+
override get message(): string {
|
|
88
|
+
return `Collector timeout reached after ${this.seconds} seconds`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class CliUsageError extends Data.TaggedError("CliUsageError")<{
|
|
93
|
+
readonly message: string;
|
|
94
|
+
}> {}
|
|
95
|
+
|
|
96
|
+
export type LogpointError =
|
|
97
|
+
| FileNotFound
|
|
98
|
+
| FileReadError
|
|
99
|
+
| FileWriteError
|
|
100
|
+
| ParseError
|
|
101
|
+
| ManifestError
|
|
102
|
+
| ValidationError
|
|
103
|
+
| PortInUse
|
|
104
|
+
| InjectionError
|
|
105
|
+
| CleanupError
|
|
106
|
+
| SecretVarBlocked
|
|
107
|
+
| CollectorTimeout
|
|
108
|
+
| CliUsageError;
|