@theholocron/dagobah 0.0.0-stage → 5.0.0-alpha.338

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Newton Koumantzelis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # `@theholocron/dagobah`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Runs a web-standard handler (`(Request, env) => Response`) locally, for a development instance behind a tunnel. It
4
+ loads the handler's secrets from a 1Password Environment, puts them in `process.env`, imports the handler, serves it
5
+ on the loopback address and says where its logs go.
6
+
7
+ Published for any project with a web-standard handler to run locally. `holocron dev serve` is built on it, and Sentinel's `scripts/serve.ts` was its first user.
8
+
9
+ > Dagobah is the swamp where Luke trains, away from the real thing. This is where a handler runs before it ships.
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { serveHandler } from "@theholocron/dagobah";
15
+
16
+ await serveHandler({
17
+ environment: "Sentinel - dev", // the 1Password Environment
18
+ required: ["GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "SENTINEL_WEBHOOK_SECRET"],
19
+ logging: { tokenVar: "SENTINEL_AXIOM_INGEST_TOKEN", datasetVar: "AXIOM_DATASET" },
20
+ port: Number(process.env.PORT ?? 3000),
21
+ handler: async () => (await import("../src/handler/index.js")).handleWebhookRequest,
22
+ });
23
+ ```
24
+
25
+ - `loadDevEnv({ environment, required })`: the process wins over the Environment; throws naming (never showing)
26
+ what is missing. An unquoted multi-line value (a PEM key, which `op` prints that way) is read whole.
27
+ - `startDevServer({ handle, env, port })`: loopback only; one log line per request (method, path, status).
28
+ - `describeLogging(env, { tokenVar, datasetVar })`: one line, the dataset and never the token.
@@ -0,0 +1,93 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** What {@link loadDevEnv} needs: which 1Password Environment holds the secrets, and which variables must be set. */
3
+ interface LoadDevEnvInput<K extends string = string> {
4
+ /** The name of the 1Password Environment, e.g. `Sentinel - dev`. */
5
+ environment: string;
6
+ /** The variables the app cannot start without. Loading throws, naming any that are in neither place. */
7
+ required: readonly K[];
8
+ /** The variables already in the process. These win over the Environment. Defaults to `process.env`. */
9
+ env?: NodeJS.ProcessEnv | undefined;
10
+ /** Runs `op`. Defaults to `spawnSync`. */
11
+ spawn?: typeof spawnSync | undefined;
12
+ }
13
+ /** The environment the handler is given: the `required` variables, set, and anything else the Environment holds. */
14
+ type DevEnv<K extends string = string> = NodeJS.ProcessEnv & Record<K, string>;
15
+ /**
16
+ * The environment a local app runs with: the variables already in the process, and what the 1Password
17
+ * Environment `environment` holds for the rest (read with `op environment secret list`, the way the 1Password
18
+ * plugin reads one, so Touch ID asks once). The process wins, so a variable exported for one run overrides the
19
+ * Environment. The `required` ones must end up set; a message names the ones that are not, and never a
20
+ * value. Nothing is written to disk.
21
+ */
22
+ export declare function loadDevEnv<K extends string>(input: LoadDevEnvInput<K>): DevEnv<K>;
23
+ /** Which variables {@link describeLogging} reads. */
24
+ interface DescribeLoggingOptions {
25
+ /** The variable holding the Axiom ingest token, e.g. `SENTINEL_AXIOM_INGEST_TOKEN`. */
26
+ tokenVar: string;
27
+ /** The variable holding the Axiom dataset, e.g. `AXIOM_DATASET`. */
28
+ datasetVar: string;
29
+ }
30
+ /**
31
+ * One line saying where this run's structured logs go: they ship to Axiom when both variables are set. Names the
32
+ * dataset (not a secret) and never the token, so a development run does not quietly write into production's dataset.
33
+ */
34
+ export declare function describeLogging(env: Record<string, string | undefined>, options: DescribeLoggingOptions): string;
35
+ /** What {@link startDevServer} needs: the handler to call, the environment to give it, and where to listen. */
36
+ interface DevServerInput<E> {
37
+ /** The handler a request is passed to, with `env`. Sentinel's is `handleWebhookRequest`. */
38
+ handle: (request: Request, env: E) => Promise<Response>;
39
+ /** The environment handed to the handler on every request. */
40
+ env: E;
41
+ /** The port to listen on. `0` picks a free one. */
42
+ port: number;
43
+ /** The only path the handler is served on; any other is a 404. Defaults to `/api/webhook`, the path Vercel serves. */
44
+ path?: string | undefined;
45
+ /** One line per request: the method, the path and the status. Never a header or a body. Defaults to nothing. */
46
+ log?: ((line: string) => void) | undefined;
47
+ }
48
+ /** A running development server. */
49
+ interface DevServer {
50
+ /** The address the handler is served on, e.g. `http://127.0.0.1:3000/api/webhook`. */
51
+ url: string;
52
+ /** Stops the server, ending any open connection. */
53
+ close: () => Promise<void>;
54
+ }
55
+ /**
56
+ * Serves a `(Request, env) => Response` handler over HTTP on the loopback address, for a development instance behind
57
+ * a tunnel: the same function the Vercel entry (e.g. Sentinel's `api/webhook.mjs`) calls, with the same web-standard `Request`. Only
58
+ * the handler's own path is served. A request is logged as one line (method, path, status) and nothing else, so no
59
+ * signature, token or payload reaches the terminal.
60
+ */
61
+ export declare function startDevServer<E>(input: DevServerInput<E>): Promise<DevServer>;
62
+ /** What {@link serveHandler} needs. */
63
+ interface ServeHandlerInput<K extends string> {
64
+ /** The 1Password Environment holding the secrets, unless the process already has every `required` variable. */
65
+ environment: string;
66
+ /** The variables the handler cannot run without. */
67
+ required: readonly K[];
68
+ /** Imports the handler. Called after the environment is in `process.env`, because a handler that builds its logger from `process.env` when its module loads would otherwise see the wrong one. */
69
+ handler: () => Promise<(request: Request, env: Record<K, string> & NodeJS.ProcessEnv) => Promise<Response>>;
70
+ /** Which variables hold the Axiom token and dataset, to say where this run's logs go. Omit to say nothing about logs. */
71
+ logging?: DescribeLoggingOptions | undefined;
72
+ /** The port to listen on. `0` picks a free one. */
73
+ port: number;
74
+ /** The only path served. Default `/api/webhook`. */
75
+ path?: string | undefined;
76
+ /** The variables in the process. Default `process.env`. */
77
+ env?: NodeJS.ProcessEnv | undefined;
78
+ /** Runs `op` to read the Environment. Default `spawnSync`; a test passes a stub so no real `op` starts. */
79
+ spawn?: typeof spawnSync | undefined;
80
+ /** One line of output. Default `console.log`. */
81
+ log?: ((line: string) => void) | undefined;
82
+ /** Called on SIGINT and SIGTERM to stop the server and exit. Default `process.exit(0)` after the server closes. */
83
+ onSignal?: ((close: () => Promise<void>) => void) | undefined;
84
+ }
85
+ /**
86
+ * Runs a web-standard handler locally: loads its environment, puts it in `process.env`, imports the handler, serves
87
+ * it on the loopback address and says where it is listening and where its logs go. The server stops, and the process
88
+ * exits, on SIGINT and SIGTERM.
89
+ *
90
+ * @returns The running server.
91
+ */
92
+ export declare function serveHandler<K extends string>(input: ServeHandlerInput<K>): Promise<DevServer>;
93
+ export type { DescribeLoggingOptions, DevEnv, DevServer, DevServerInput, LoadDevEnvInput, ServeHandlerInput };
package/dist/index.mjs ADDED
@@ -0,0 +1,155 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { parse } from "dotenv";
3
+ import { createServer } from "node:http";
4
+ // src/quote-pem/quote-pem.ts
5
+ /**
6
+ * Wrap an unquoted PEM block in double quotes. `op environment secret list --format=dotenv` prints a multi-line
7
+ * value (a private key) unquoted, which `dotenv` reads as its first line only; quoted, it reads the whole block.
8
+ * A PEM holds no quote or backslash, so nothing needs escaping.
9
+ */
10
+ function quotePem(text) {
11
+ return text.replace(/^([A-Za-z_][A-Za-z0-9_]*)=(-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----)[ \t]*$/gm, "$1=\"$2\"");
12
+ }
13
+ // src/dev-env/dev-env.ts
14
+ /**
15
+ * The environment a local app runs with: the variables already in the process, and what the 1Password
16
+ * Environment `environment` holds for the rest (read with `op environment secret list`, the way the 1Password
17
+ * plugin reads one, so Touch ID asks once). The process wins, so a variable exported for one run overrides the
18
+ * Environment. The `required` ones must end up set; a message names the ones that are not, and never a
19
+ * value. Nothing is written to disk.
20
+ */
21
+ function loadDevEnv(input) {
22
+ const REQUIRED = input.required;
23
+ const env = input.env ?? process.env;
24
+ const present = (name) => (env[name] ?? "") !== "";
25
+ let merged = env;
26
+ if (!REQUIRED.every(present)) {
27
+ const result = (input.spawn ?? spawnSync)("op", [
28
+ "environment",
29
+ "secret",
30
+ "list",
31
+ input.environment,
32
+ "--reveal",
33
+ "--format=dotenv"
34
+ ], {
35
+ encoding: "utf8",
36
+ stdio: [
37
+ "inherit",
38
+ "pipe",
39
+ "pipe"
40
+ ]
41
+ });
42
+ if (result.error || result.status !== 0) {
43
+ const why = result.error?.message ?? result.stderr.trim().split("\n")[0];
44
+ throw new Error(`could not read the 1Password Environment "${input.environment}": ${why || "op failed"}`);
45
+ }
46
+ merged = {
47
+ ...parse(quotePem(String(result.stdout))),
48
+ ...Object.fromEntries(Object.entries(env).filter(([, v]) => v !== void 0 && v !== ""))
49
+ };
50
+ }
51
+ const missing = REQUIRED.filter((name) => (merged[name] ?? "") === "");
52
+ if (missing.length > 0) throw new Error(`missing from the environment and from "${input.environment}": ${missing.join(", ")}`);
53
+ return merged;
54
+ }
55
+ // src/dev-logging/dev-logging.ts
56
+ /**
57
+ * One line saying where this run's structured logs go: they ship to Axiom when both variables are set. Names the
58
+ * dataset (not a secret) and never the token, so a development run does not quietly write into production's dataset.
59
+ */
60
+ function describeLogging(env, options) {
61
+ const token = env[options.tokenVar];
62
+ const dataset = env[options.datasetVar];
63
+ return token && dataset ? `logs: shipped to Axiom, dataset "${dataset}"` : `logs: not shipped to Axiom (${options.tokenVar} and ${options.datasetVar} are not both set)`;
64
+ }
65
+ // src/dev-server/dev-server.ts
66
+ const LOOPBACK = "127.0.0.1";
67
+ /** GitHub caps a webhook payload at 25 MB; anything larger is not one. */
68
+ const MAX_BODY = 27262976;
69
+ /** The request body, or `undefined` when it is larger than a webhook can be. */
70
+ async function readBody(req) {
71
+ const chunks = [];
72
+ let size = 0;
73
+ for await (const chunk of req) {
74
+ size += chunk.length;
75
+ if (size > MAX_BODY) return void 0;
76
+ chunks.push(chunk);
77
+ }
78
+ return Buffer.concat(chunks);
79
+ }
80
+ /**
81
+ * Serves a `(Request, env) => Response` handler over HTTP on the loopback address, for a development instance behind
82
+ * a tunnel: the same function the Vercel entry (e.g. Sentinel's `api/webhook.mjs`) calls, with the same web-standard `Request`. Only
83
+ * the handler's own path is served. A request is logged as one line (method, path, status) and nothing else, so no
84
+ * signature, token or payload reaches the terminal.
85
+ */
86
+ async function startDevServer(input) {
87
+ const path = input.path ?? "/api/webhook";
88
+ const log = input.log ?? (() => {});
89
+ const server = createServer(async (req, res) => {
90
+ const method = req.method;
91
+ const url = new URL(req.url, `http://${LOOPBACK}`);
92
+ const reply = (status, body, headers = {}) => {
93
+ log(`${method} ${url.pathname} ${status}`);
94
+ res.writeHead(status, headers);
95
+ res.end(body);
96
+ };
97
+ if (url.pathname !== path) return reply(404, "Not Found");
98
+ const hasBody = method !== "GET" && method !== "HEAD";
99
+ const body = hasBody ? await readBody(req) : void 0;
100
+ if (hasBody && body === void 0) return reply(413, "Payload Too Large");
101
+ const headers = new Headers();
102
+ for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
103
+ try {
104
+ const response = await input.handle(new Request(`http://${req.headers.host}${req.url}`, {
105
+ method,
106
+ headers,
107
+ ...body ? { body } : {}
108
+ }), input.env);
109
+ reply(response.status, Buffer.from(await response.arrayBuffer()), Object.fromEntries(response.headers));
110
+ } catch {
111
+ reply(500, "Internal Server Error");
112
+ }
113
+ });
114
+ await new Promise((resolve) => server.listen(input.port, LOOPBACK, resolve));
115
+ const { port } = server.address();
116
+ return {
117
+ url: `http://${LOOPBACK}:${port}${path}`,
118
+ close: () => new Promise((resolve) => {
119
+ server.close(() => resolve());
120
+ server.closeAllConnections();
121
+ })
122
+ };
123
+ }
124
+ // src/serve-handler/serve-handler.ts
125
+ /**
126
+ * Runs a web-standard handler locally: loads its environment, puts it in `process.env`, imports the handler, serves
127
+ * it on the loopback address and says where it is listening and where its logs go. The server stops, and the process
128
+ * exits, on SIGINT and SIGTERM.
129
+ *
130
+ * @returns The running server.
131
+ */
132
+ async function serveHandler(input) {
133
+ const log = input.log ?? ((line) => console.log(line));
134
+ const settings = input.env ?? process.env;
135
+ const env = loadDevEnv({
136
+ environment: input.environment,
137
+ required: input.required,
138
+ env: settings,
139
+ ...input.spawn ? { spawn: input.spawn } : {}
140
+ });
141
+ Object.assign(process.env, env);
142
+ const server = await startDevServer({
143
+ handle: await input.handler(),
144
+ env,
145
+ port: input.port,
146
+ ...input.path ? { path: input.path } : {},
147
+ log
148
+ });
149
+ log(`listening on ${server.url}, secrets from "${input.environment}"`);
150
+ if (input.logging) log(describeLogging(env, input.logging));
151
+ const stop = input.onSignal ?? ((close) => void close().then(() => process.exit(0)));
152
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => stop(server.close));
153
+ return server;
154
+ }
155
+ export { describeLogging, loadDevEnv, serveHandler, startDevServer };
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@theholocron/dagobah",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "5.0.0-alpha.338",
4
+ "description": "Local development server for a web-standard handler: loads its secrets from a 1Password Environment and serves it over HTTP behind a tunnel.",
5
+ "keywords": [
6
+ "dev-server",
7
+ "holocron",
8
+ "theholocron",
9
+ "tunnel"
10
+ ],
11
+ "homepage": "https://docs.theholocron.dev/holocron/dagobah/",
12
+ "bugs": "https://github.com/theholocron/holocron/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/theholocron/holocron.git",
16
+ "directory": "cli/dagobah"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Newton Koumantzelis",
20
+ "sideEffects": false,
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.mts",
25
+ "import": "./dist/index.mjs",
26
+ "default": "./dist/index.mjs"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "dependencies": {
33
+ "dotenv": "^17.4.2"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^26",
37
+ "@vitest/coverage-v8": "^5.0.5",
38
+ "@vitest/eslint-plugin": "^1.6.28",
39
+ "eslint": "^10.14.0",
40
+ "eslint-plugin-n": "^18.5.0",
41
+ "globals": "^17.14.0",
42
+ "tsdown": "^0.23.4",
43
+ "typescript": "^5.9.3",
44
+ "vitest": "^5.0.5",
45
+ "@theholocron/eslint-config": "^8.9.0-alpha.8",
46
+ "@theholocron/tsdown-config": "^8.9.0-alpha.8",
47
+ "@theholocron/vitest-config": "^8.9.0-alpha.8",
48
+ "@theholocron/tsconfig": "^8.9.0-alpha.8"
49
+ },
50
+ "engines": {
51
+ "node": ">=22"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "scripts": {
57
+ "build": "tsdown",
58
+ "lint:code": "eslint .",
59
+ "test:unit": "vitest run --coverage",
60
+ "test:watch": "vitest",
61
+ "typecheck": "tsc --noEmit"
62
+ }
6
63
  }