@philkrpp/phase-loader 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philipp Krapp
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,123 @@
1
- # Temporary Holding Version
1
+ # @philkrpp/phase-loader
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
+ Load env values from a self-hosted [Phase](https://phase.dev) secrets manager at dev time, without the Phase CLI.
4
+ Values go into a child process (`phase-loader run`) or are returned to your code (`loadPhaseEnv`); they are never
5
+ written to disk or printed. Built on the official SDK [`@phase.dev/phase-node`](https://github.com/phasehq/node-sdk).
6
+
7
+ Requires Node.js 22 or newer.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pnpm add -D @philkrpp/phase-loader
13
+ ```
14
+
15
+ ## Configure: `phase.config.json`
16
+
17
+ Commit one `phase.config.json` at the repository root (found by walking up from the current directory). It holds no
18
+ secrets: host, app ids, paths and keyring account names only.
19
+
20
+ ```json
21
+ {
22
+ "host": "https://phase.example.com",
23
+ "targets": {
24
+ "dev": {
25
+ "env": "Development",
26
+ "account": "my-repo/dev",
27
+ "layers": [
28
+ { "app": "my-repo", "appId": "00000000-0000-4000-8000-000000000000", "path": "/" },
29
+ { "app": "my-repo", "appId": "00000000-0000-4000-8000-000000000000", "path": "/local" }
30
+ ],
31
+ "personal": { "account": "my-repo/personal-app" }
32
+ }
33
+ },
34
+ "require": ["DATABASE_URL"],
35
+ "allowLiteral": []
36
+ }
37
+ ```
38
+
39
+ | Field | Meaning |
40
+ | --- | --- |
41
+ | `host` | Phase host, `https://` only, no credentials in the URL. |
42
+ | `targets.<name>.env` | Phase environment name, e.g. `Development`. |
43
+ | `targets.<name>.account` | OS keyring account (service `phase`) holding the service token for this target. |
44
+ | `targets.<name>.layers` | Apps (`appId` = Phase app UUID; `app` = name for messages) and paths, exported in order; later layers win. |
45
+ | `targets.<name>.personal.account` | Optional keyring account holding a personal app id or name; exported last (path `/`). |
46
+ | `require` | Keys the merged export must contain; otherwise loading fails and lists the missing names. |
47
+ | `allowLiteral` | Keys whose value may start with `${`. Any other value starting with `${` is an unresolved Phase reference and fails the load. |
48
+
49
+ ## Tokens
50
+
51
+ The token of a target is read from the OS keyring: service `phase`, account `targets.<name>.account`.
52
+
53
+ The env variable `PHASE_TOKEN_<ACCOUNT>` (account upper-cased, every non-alphanumeric character replaced by `_`,
54
+ e.g. `my-repo/dev` → `PHASE_TOKEN_MY_REPO_DEV`) is used instead when `PHASE_TOKEN_SOURCE=env` is set, or when the
55
+ keyring is unavailable or has no entry.
56
+
57
+ The personal app comes from `PHASE_PERSONAL_APP` (app id or name), else from the keyring account
58
+ `personal.account`. If neither is set, the layer is skipped and a line on stderr says why. The same token reads it.
59
+
60
+ ## Run a command
61
+
62
+ ```sh
63
+ phase-loader run dev -- node server.js
64
+ ```
65
+
66
+ Spawns the command through `cross-spawn` (Windows `.cmd` shims such as pnpm work), never with `shell: true`. Its env is `process.env` plus the Phase values plus `PHASE_ENV_LOADED=<target>`.
67
+ SIGINT and SIGTERM are forwarded; the exit code is the child's. On stderr:
68
+
69
+ ```
70
+ [env] loaded 12 keys from Phase (dev: my-repo / + my-repo /local + personal /)
71
+ ```
72
+
73
+ ## Set up a token
74
+
75
+ ```sh
76
+ phase-loader setup dev # hidden prompt, stores the service token in the OS keyring
77
+ phase-loader setup dev --personal # stores the personal app id or name
78
+ ```
79
+
80
+ The value is written to service `phase`, account `targets.<name>.account` (or `personal.account`). Empty input
81
+ changes nothing; the value is never printed. Consumer repos typically add `"phase:setup": "phase-loader setup"`.
82
+
83
+ ## `.env.example` and drift check
84
+
85
+ ```sh
86
+ phase-loader example # name diff between Phase (target dev) and .env.example
87
+ phase-loader example --write # write .env.example: sections "# <app> <path>", sorted KEY= lines, no values
88
+ phase-loader check --offline # .env.example vs env reads in git-tracked files; no token, no network
89
+ phase-loader check # additionally vs the key names in Phase
90
+ ```
91
+
92
+ `check` recognises `process.env.X`, `process.env["X"]`, `import.meta.env.X`, `os.environ["X"]` and `os.getenv("X")`
93
+ in tracked `.js/.ts/.vue/.py` files (not under `node_modules`), ignores runtime built-ins such as `NODE_ENV`, prints
94
+ names only and exits 1 on any undocumented or orphan name. The personal app is not part of `.env.example`.
95
+
96
+ ## API
97
+
98
+ ```ts
99
+ import { loadPhaseEnv, PhaseLoaderError } from "@philkrpp/phase-loader";
100
+
101
+ const { env, layers } = await loadPhaseEnv("dev", { keep: ["PORT"] });
102
+ Object.assign(process.env, env);
103
+ ```
104
+
105
+ - `loadConfig(cwd)` returns the validated config.
106
+ - `readToken(account)` returns the token of a keyring account.
107
+ - `loadPhaseEnv(target, { cwd?, keep? })` returns `{ env, layers }` and does not touch `process.env`. `keep` lists
108
+ keys whose existing `process.env` value wins; they are left out of `env`.
109
+ - `loadPhaseEnvSync(target, { cwd?, keep? })` blocks, assigns the values to `process.env` (keys in `keep` that are
110
+ already set stay untouched) and returns `{ env, layers }`. It runs the same SDK child through `execFileSync`; use it
111
+ where nothing can be awaited, e.g. a config module evaluated at import time.
112
+ - `run(target, argv)` resolves with the child's exit code.
113
+ - Every expected failure is a `PhaseLoaderError` whose message names keys, apps, paths, accounts or status codes,
114
+ never a value or a token.
115
+
116
+ The SDK runs in a short-lived child process (token via stdin, its stderr discarded, 60 s timeout, minimal env: `PATH`, home, proxy and CA variables only), so SDK errors
117
+ and hangs end as a `PhaseLoaderError` naming the app, path and status. Phase cross-app references (`${app::Env.KEY}`)
118
+ are resolved by the SDK. A reference the token cannot read stays
119
+ literal in Phase's output, so `phase-loader` fails on it and names the key instead of starting with `${…}`.
120
+
121
+ ## License
122
+
123
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import { a as KEYRING_SERVICE, c as loadConfig, l as PhaseLoaderError, n as fetchLayers, s as writeKeyring, t as run } from "./run-QGYeawST.js";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { createInterface } from "node:readline";
7
+ import { Writable } from "node:stream";
8
+ // src/example.ts
9
+ const EXAMPLE_FILE = ".env.example";
10
+ const EXAMPLE_TARGET = "dev";
11
+ /** Key names of the committed layers of `dev` (the personal app is not part of the repo's contract), by section. */
12
+ async function phaseNames(cwd) {
13
+ const { config, layers } = await fetchLayers(EXAMPLE_TARGET, cwd);
14
+ const seen = /* @__PURE__ */ new Set();
15
+ const sections = layers.filter((layer) => layer.label !== "personal /").map((layer) => {
16
+ const names = layer.entries.map(([key]) => key).filter((key) => !seen.has(key)).sort();
17
+ for (const key of names) seen.add(key);
18
+ return {
19
+ label: layer.label,
20
+ names
21
+ };
22
+ });
23
+ return {
24
+ dir: config.dir,
25
+ sections
26
+ };
27
+ }
28
+ /** Names (`KEY=` lines) of an `.env.example` text. */
29
+ function exampleNames(text) {
30
+ return [...text.matchAll(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map((m) => m[1]);
31
+ }
32
+ /**
33
+ * Builds `.env.example` from the key names of target `dev` (sections `# <app> <path>`, `KEY=` lines, sorted).
34
+ * `write` replaces the file in the config directory; otherwise the result is a name diff against the current file.
35
+ * Returns the text to print; values are never read into the output.
36
+ */
37
+ async function example(opts = {}) {
38
+ const { dir, sections } = await phaseNames(opts.cwd ?? process.cwd());
39
+ const text = sections.filter((s) => s.names.length > 0).map((s) => `# ${s.label}\n${s.names.map((n) => `${n}=`).join("\n")}\n`).join("\n");
40
+ const file = join(dir, EXAMPLE_FILE);
41
+ if (opts.write) {
42
+ writeFileSync(file, text);
43
+ return `Wrote ${EXAMPLE_FILE} (${sections.reduce((n, s) => n + s.names.length, 0)} names).`;
44
+ }
45
+ const current = new Set(existsSync(file) ? exampleNames(readFileSync(file, "utf8")) : []);
46
+ const wanted = new Set(sections.flatMap((s) => s.names));
47
+ const lines = [...[...wanted].filter((n) => !current.has(n)).sort().map((n) => `+ ${n}`), ...[...current].filter((n) => !wanted.has(n)).sort().map((n) => `- ${n}`)];
48
+ return lines.length > 0 ? `${lines.join("\n")}\n(run with --write to update ${EXAMPLE_FILE})` : `${EXAMPLE_FILE} is up to date.`;
49
+ }
50
+ // src/check.ts
51
+ const SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|py)$/;
52
+ const USAGE$1 = [
53
+ /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
54
+ /process\.env\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]/g,
55
+ /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
56
+ /os\.environ\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]/g,
57
+ /os\.getenv\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g
58
+ ];
59
+ /** Provided by the runtime or by phase-loader itself, never documented in `.env.example`. */
60
+ const BUILT_IN = /* @__PURE__ */ new Set([
61
+ "NODE_ENV",
62
+ "MODE",
63
+ "DEV",
64
+ "PROD",
65
+ "SSR",
66
+ "BASE_URL",
67
+ "CI",
68
+ "PHASE_ENV_LOADED"
69
+ ]);
70
+ function trackedFiles(dir) {
71
+ try {
72
+ return execFileSync("git", ["ls-files", "-z"], {
73
+ cwd: dir,
74
+ encoding: "utf8",
75
+ stdio: [
76
+ "ignore",
77
+ "pipe",
78
+ "ignore"
79
+ ]
80
+ }).split("\0").filter(Boolean);
81
+ } catch {
82
+ throw new PhaseLoaderError(`"git ls-files" failed in ${dir}: check needs a git repository.`);
83
+ }
84
+ }
85
+ function usedNames(dir) {
86
+ const used = /* @__PURE__ */ new Set();
87
+ for (const file of trackedFiles(dir)) {
88
+ if (!SOURCE.test(file) || file.split("/").includes("node_modules")) continue;
89
+ let text;
90
+ try {
91
+ text = readFileSync(join(dir, file), "utf8");
92
+ } catch {
93
+ continue;
94
+ }
95
+ for (const pattern of USAGE$1) for (const m of text.matchAll(pattern)) if (!BUILT_IN.has(m[1])) used.add(m[1]);
96
+ }
97
+ return used;
98
+ }
99
+ /**
100
+ * Offline: names in `.env.example` against env reads in tracked source files (undocumented / orphan).
101
+ * Online: also against the key names in Phase (`dev`). Names only, never values. `ok` is false on any finding.
102
+ */
103
+ async function check(opts = {}) {
104
+ const dir = loadConfig(opts.cwd ?? process.cwd()).dir;
105
+ const file = join(dir, EXAMPLE_FILE);
106
+ if (!existsSync(file)) throw new PhaseLoaderError(`${EXAMPLE_FILE} not found in ${dir} (create it with "phase-loader example --write").`);
107
+ const documented = new Set(exampleNames(readFileSync(file, "utf8")));
108
+ const used = usedNames(dir);
109
+ const findings = [[`Used in code but undocumented in ${EXAMPLE_FILE}:`, [...used].filter((n) => !documented.has(n))], [`Orphan in ${EXAMPLE_FILE} (never read in tracked code):`, [...documented].filter((n) => !used.has(n))]];
110
+ if (!opts.offline) {
111
+ const inPhase = new Set((await phaseNames(opts.cwd ?? process.cwd())).sections.flatMap((s) => s.names));
112
+ findings.push([`In ${EXAMPLE_FILE} but not in Phase:`, [...documented].filter((n) => !inPhase.has(n))], [`In Phase but not in ${EXAMPLE_FILE}:`, [...inPhase].filter((n) => !documented.has(n))]);
113
+ }
114
+ const lines = findings.filter(([, names]) => names.length > 0).flatMap(([title, names]) => [title, ` ${names.sort().join(", ")}`]);
115
+ return lines.length > 0 ? {
116
+ ok: false,
117
+ report: lines.join("\n")
118
+ } : {
119
+ ok: true,
120
+ report: "Env names are consistent."
121
+ };
122
+ }
123
+ // src/setup.ts
124
+ /** Prompts on `output` and reads one line from `input` without echoing it. */
125
+ function readHidden(prompt, input, output) {
126
+ let muted = false;
127
+ const sink = new Writable({ write(chunk, _encoding, callback) {
128
+ if (!muted) output.write(chunk);
129
+ callback();
130
+ } });
131
+ const rl = createInterface({
132
+ input,
133
+ output: sink,
134
+ terminal: Boolean(input.isTTY)
135
+ });
136
+ return new Promise((resolve) => {
137
+ let settled = false;
138
+ const done = (answer) => {
139
+ if (settled) return;
140
+ settled = true;
141
+ output.write("\n");
142
+ rl.close();
143
+ resolve(answer);
144
+ };
145
+ rl.once("close", () => done(""));
146
+ rl.question(prompt, done);
147
+ muted = true;
148
+ });
149
+ }
150
+ /**
151
+ * Stores a token (or, with `personal`, the personal app id/name) in the OS keyring, service `phase`, account from
152
+ * `phase.config.json`. Empty input changes nothing. Returns the message to print; it never contains the value.
153
+ */
154
+ async function setup(target, opts = {}) {
155
+ const config = loadConfig(opts.cwd ?? process.cwd());
156
+ const t = Object.hasOwn(config.targets, target) ? config.targets[target] : void 0;
157
+ if (!t) throw new PhaseLoaderError(`Unknown target "${target}" (phase.config.json defines: ${Object.keys(config.targets).join(", ")}).`);
158
+ if (opts.personal && !t.personal) throw new PhaseLoaderError(`Target "${target}" has no "personal" account configured.`);
159
+ const account = opts.personal ? t.personal.account : t.account;
160
+ const what = opts.personal ? "personal app (id or name)" : "service token";
161
+ const value = (await (opts.ask ?? ((prompt) => readHidden(prompt, process.stdin, process.stderr)))(`Phase ${what} for "${account}" (empty = keep current): `)).trim();
162
+ if (!value) return `Unchanged: keyring account "${account}".`;
163
+ writeKeyring(account, value);
164
+ return `Stored in the OS keyring (service "${KEYRING_SERVICE}", account "${account}").`;
165
+ }
166
+ // src/cli.ts
167
+ const USAGE = `Usage:
168
+ phase-loader setup <target> [--personal]
169
+ phase-loader run <target> -- <command> [args...]
170
+ phase-loader example [--write]
171
+ phase-loader check [--offline]`;
172
+ /** Exit code of the command, or 2 for a usage error. */
173
+ async function main(argv) {
174
+ const [command, ...args] = argv;
175
+ const flags = (allowed) => args.every((a) => a.startsWith("--") && allowed.includes(a)) ? args : void 0;
176
+ if (command === "run") {
177
+ const [target, separator, ...rest] = args;
178
+ if (!target || separator !== "--" || rest.length === 0) return usage();
179
+ return run(target, rest);
180
+ }
181
+ if (command === "setup") {
182
+ const [target, ...flagArgs] = args;
183
+ if (!target || target.startsWith("--") || !flagArgs.every((a) => a === "--personal")) return usage();
184
+ process.stderr.write(`${await setup(target, { personal: flagArgs.includes("--personal") })}\n`);
185
+ return 0;
186
+ }
187
+ if (command === "example") {
188
+ const given = flags(["--write"]);
189
+ if (!given) return usage();
190
+ process.stdout.write(`${await example({ write: given.includes("--write") })}\n`);
191
+ return 0;
192
+ }
193
+ if (command === "check") {
194
+ const given = flags(["--offline"]);
195
+ if (!given) return usage();
196
+ const { ok, report } = await check({ offline: given.includes("--offline") });
197
+ process.stdout.write(`${report}\n`);
198
+ return ok ? 0 : 1;
199
+ }
200
+ return usage();
201
+ }
202
+ function usage() {
203
+ process.stderr.write(`${USAGE}\n`);
204
+ return 2;
205
+ }
206
+ try {
207
+ process.exitCode = await main(process.argv.slice(2));
208
+ } catch (error) {
209
+ const message = error instanceof PhaseLoaderError ? error.message : `unexpected ${error?.name ?? "error"}`;
210
+ process.stderr.write(`phase-loader: ${message}\n`);
211
+ process.exitCode = 1;
212
+ }
213
+ export {};
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,87 @@
1
+ import Phase from "@phase.dev/phase-node";
2
+ // src/fetch-core.ts
3
+ const UNREACHABLE = /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|Network Error|timeout|Failed to initialize session/i;
4
+ /** Short reason for an SDK error; nothing of the error text itself leaves the child. */
5
+ function failureReason(error) {
6
+ const text = typeof error === "string" ? error : error instanceof Error ? error.message : "";
7
+ const status = /^Error: (\d{3}):/.exec(text)?.[1];
8
+ if (status) return status;
9
+ if (text === "Invalid app id") return "invalid-app";
10
+ if (text.startsWith("Invalid environment name")) return "invalid-env";
11
+ if (text.startsWith("Invalid token format")) return "invalid-token";
12
+ if (UNREACHABLE.test(text)) return "unreachable";
13
+ return "error";
14
+ }
15
+ /** Reads a FetchRequest from stdin, exports every layer, writes FetchMessages to stdout and exits. */
16
+ async function runFetchChild(Phase) {
17
+ let current = "init";
18
+ let finished = false;
19
+ const finish = (message, code) => {
20
+ if (finished) return;
21
+ finished = true;
22
+ process.stdout.write(`${JSON.stringify(message)}\n`, () => process.exit(code));
23
+ };
24
+ const fail = (error) => finish({
25
+ ok: false,
26
+ error: `${current} (${failureReason(error)})`
27
+ }, 1);
28
+ const progress = (label) => {
29
+ current = label;
30
+ process.stdout.write(`${JSON.stringify({ progress: label })}\n`);
31
+ };
32
+ process.on("unhandledRejection", fail);
33
+ process.on("uncaughtException", fail);
34
+ try {
35
+ const chunks = [];
36
+ for await (const chunk of process.stdin) chunks.push(chunk);
37
+ const request = JSON.parse(Buffer.concat(chunks).toString("utf8"));
38
+ progress("init");
39
+ const phase = new Phase(request.token, request.host);
40
+ await phase.init();
41
+ const layers = request.layers.map((l) => ({
42
+ label: `${l.app} ${l.path}`,
43
+ appId: l.appId,
44
+ path: l.path
45
+ }));
46
+ if (request.personal) {
47
+ progress("personal /");
48
+ const apps = phase.apps ?? [];
49
+ const byId = apps.filter((a) => a.id === request.personal);
50
+ const matches = byId.length > 0 ? byId : apps.filter((a) => a.name === request.personal);
51
+ if (matches.length !== 1) {
52
+ finish({
53
+ ok: false,
54
+ error: `personal / (${matches.length === 0 ? "invalid-app" : "ambiguous-app"})`
55
+ }, 1);
56
+ return;
57
+ }
58
+ layers.push({
59
+ label: "personal /",
60
+ appId: matches[0].id,
61
+ path: "/"
62
+ });
63
+ }
64
+ const fetched = [];
65
+ for (const layer of layers) {
66
+ progress(layer.label);
67
+ const result = await phase.get({
68
+ appId: layer.appId,
69
+ envName: request.envName,
70
+ path: layer.path
71
+ });
72
+ fetched.push({
73
+ label: layer.label,
74
+ entries: result.map((s) => [s.key, s.value])
75
+ });
76
+ }
77
+ finish({
78
+ ok: true,
79
+ layers: fetched
80
+ }, 0);
81
+ } catch (error) {
82
+ fail(error);
83
+ }
84
+ }
85
+ // src/fetch-child.ts
86
+ await runFetchChild(Phase);
87
+ export {};
@@ -0,0 +1,75 @@
1
+ /** One Phase app + path exported as a layer; layers merge in order, later wins. */
2
+ interface PhaseLayer {
3
+ /** App name, used in messages only. */
4
+ app: string;
5
+ /** App id (UUID); the SDK addresses apps by id. */
6
+ appId: string;
7
+ path: string;
8
+ }
9
+ interface PhaseTarget {
10
+ /** Phase environment name, e.g. `Development`. */
11
+ env: string;
12
+ /** Keyring account (service `phase`) holding the token for this target. */
13
+ account: string;
14
+ layers: PhaseLayer[];
15
+ /** Keyring account holding the personal app id or name; exported last with path `/`. */
16
+ personal?: {
17
+ account: string;
18
+ };
19
+ }
20
+ interface PhaseConfig {
21
+ /** Directory that contains `phase.config.json`. */
22
+ dir: string;
23
+ host: string;
24
+ targets: Record<string, PhaseTarget>;
25
+ /** Keys the merged export must contain. */
26
+ require: string[];
27
+ /** Keys whose value may start with `${` (not a Phase reference). */
28
+ allowLiteral: string[];
29
+ }
30
+ /** Finds the nearest `phase.config.json` from `cwd` upwards, validates and returns it. */
31
+ export declare function loadConfig(cwd: string): PhaseConfig;
32
+ /**
33
+ * The only error this package throws on purpose. Messages name keys, apps, paths, accounts and status codes only,
34
+ * never a secret value or a token.
35
+ */
36
+ export declare class PhaseLoaderError extends Error {
37
+ constructor(message: string);
38
+ }
39
+ interface LoadedPhaseEnv {
40
+ /** Merged values; never print them. */
41
+ env: Record<string, string>;
42
+ /** Exported layers in order, e.g. `["repo /", "repo /local", "personal /"]`. */
43
+ layers: string[];
44
+ }
45
+ /**
46
+ * Exports the layers of `target` from Phase and returns the merged values without touching `process.env`.
47
+ * Layers merge in order (later wins), the personal app comes last. The SDK runs in a child process (it can throw
48
+ * outside its promises) and resolves `${…}` references; a value that still starts with `${` (unreadable reference)
49
+ * throws unless the key is in `allowLiteral`.
50
+ * `keep`: keys whose existing `process.env` value wins; they are left out of the result.
51
+ */
52
+ export declare function loadPhaseEnv(target: string, opts?: {
53
+ cwd?: string;
54
+ keep?: string[];
55
+ }): Promise<LoadedPhaseEnv>;
56
+ /**
57
+ * Blocking variant for code that cannot await (e.g. a config module loaded at import time): same child, same
58
+ * checks, but the values are assigned to `process.env` (keys in `keep` that are already set stay untouched).
59
+ * The child's output is captured through pipes, never inherited.
60
+ */
61
+ export declare function loadPhaseEnvSync(target: string, opts?: {
62
+ cwd?: string;
63
+ keep?: string[];
64
+ }): LoadedPhaseEnv;
65
+ /**
66
+ * Loads `target` and runs `argv` (no `shell: true`; cross-spawn resolves Windows `.cmd` shims) with `process.env` + the Phase values + `PHASE_ENV_LOADED=<target>`.
67
+ * SIGINT/SIGTERM are forwarded to the child; resolves with its exit code (128 + signal number when killed).
68
+ */
69
+ export declare function run(target: string, argv: string[]): Promise<number>;
70
+ /**
71
+ * Token of a keyring account: the OS keyring (service `phase`) unless `PHASE_TOKEN_SOURCE=env`; the env variable
72
+ * {@link tokenEnvVar} when the keyring is skipped, unavailable or has no entry.
73
+ */
74
+ export declare function readToken(account: string): string;
75
+ export type { LoadedPhaseEnv, PhaseConfig, PhaseLayer, PhaseTarget };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { l as PhaseLoaderError, c as loadConfig, r as loadPhaseEnv, i as loadPhaseEnvSync, o as readToken, t as run } from "./run-QGYeawST.js";
@@ -0,0 +1,407 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Entry } from "@napi-rs/keyring";
6
+ import { constants } from "node:os";
7
+ import spawn$1 from "cross-spawn";
8
+ // src/errors.ts
9
+ /**
10
+ * The only error this package throws on purpose. Messages name keys, apps, paths, accounts and status codes only,
11
+ * never a secret value or a token.
12
+ */
13
+ var PhaseLoaderError = class extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "PhaseLoaderError";
17
+ }
18
+ };
19
+ // src/config.ts
20
+ const CONFIG_FILE = "phase.config.json";
21
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
22
+ const ENV_NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
23
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ const PATH = /^\/[A-Za-z0-9._/-]*$/;
25
+ const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
26
+ const ACCOUNT = /^[^\p{Cc}]{1,200}$/u;
27
+ /** Finds the nearest `phase.config.json` from `cwd` upwards, validates and returns it. */
28
+ function loadConfig(cwd) {
29
+ let dir = resolve(cwd);
30
+ for (;;) {
31
+ const file = join(dir, CONFIG_FILE);
32
+ if (existsSync(file)) return parseConfig(file, dir);
33
+ const parent = dirname(dir);
34
+ if (parent === dir) throw new PhaseLoaderError(`No ${CONFIG_FILE} found in ${cwd} or any parent directory.`);
35
+ dir = parent;
36
+ }
37
+ }
38
+ function parseConfig(file, dir) {
39
+ let raw;
40
+ try {
41
+ raw = JSON.parse(readFileSync(file, "utf8"));
42
+ } catch {
43
+ throw new PhaseLoaderError(`${file} is not readable JSON.`);
44
+ }
45
+ const fail = (what) => {
46
+ throw new PhaseLoaderError(`Invalid ${CONFIG_FILE} (${file}): ${what}.`);
47
+ };
48
+ const str = (value, pattern, what) => typeof value === "string" && pattern.test(value) ? value : fail(what);
49
+ const keys = (value, field) => {
50
+ if (value === void 0) return [];
51
+ if (!Array.isArray(value)) return fail(`"${field}" must be a list of key names`);
52
+ return value.map((key) => str(key, KEY, `"${field}" must contain env key names`));
53
+ };
54
+ if (!isObject$1(raw)) return fail("expected an object");
55
+ const host = str(raw.host, /^https:\/\/[^\s/@]+(\/[^\s@]*)?$/, `"host" must be an https:// URL without credentials`).replace(/\/+$/, "");
56
+ if (!isObject$1(raw.targets) || Object.keys(raw.targets).length === 0) return fail(`"targets" must be a non-empty object`);
57
+ const targets = {};
58
+ for (const [name, value] of Object.entries(raw.targets)) {
59
+ const at = `target "${name}"`;
60
+ str(name, NAME, `${at} has an invalid name`);
61
+ if (!isObject$1(value)) return fail(`${at} must be an object`);
62
+ if (!Array.isArray(value.layers) || value.layers.length === 0) return fail(`${at}: "layers" must be a non-empty list`);
63
+ const target = {
64
+ env: str(value.env, ENV_NAME, `${at}: "env" must be a Phase environment name`),
65
+ account: str(value.account, ACCOUNT, `${at}: "account" must be a keyring account name`),
66
+ layers: value.layers.map((layer, i) => {
67
+ const li = `${at} layer ${i}`;
68
+ if (!isObject$1(layer)) return fail(`${li} must be an object`);
69
+ return {
70
+ app: str(layer.app, NAME, `${li}: "app" must be a Phase app name`),
71
+ appId: str(layer.appId, UUID, `${li}: "appId" must be a Phase app id (UUID)`),
72
+ path: str(layer.path, PATH, `${li}: "path" must start with "/"`)
73
+ };
74
+ })
75
+ };
76
+ if (value.personal !== void 0) {
77
+ if (!isObject$1(value.personal)) return fail(`${at}: "personal" must be an object`);
78
+ target.personal = { account: str(value.personal.account, ACCOUNT, `${at}: "personal.account" must be a keyring account name`) };
79
+ }
80
+ targets[name] = target;
81
+ }
82
+ return {
83
+ dir,
84
+ host,
85
+ targets,
86
+ require: keys(raw.require, "require"),
87
+ allowLiteral: keys(raw.allowLiteral, "allowLiteral")
88
+ };
89
+ }
90
+ function isObject$1(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ }
93
+ // src/fetch-protocol.ts
94
+ const FETCH_TIMEOUT_MS = 6e4;
95
+ const LABEL = /^(?:init|personal \/|[A-Za-z0-9][A-Za-z0-9._-]* \/[A-Za-z0-9._/-]*)$/;
96
+ const REASON = /^(?:\d{3}|timeout|unreachable|invalid-app|invalid-env|invalid-token|ambiguous-app|crashed|invalid-response|error)$/;
97
+ const FAILURE = /^(.+) \(([a-z0-9-]+)\)$/;
98
+ /**
99
+ * Turns the child's stdout and exit into the fetched layers, or throws a {@link PhaseLoaderError} built only from
100
+ * validated labels and reasons (never from free text of the child).
101
+ */
102
+ function readFetchOutput(stdout, outcome, ctx) {
103
+ let last = "init";
104
+ let response;
105
+ for (const line of stdout.split("\n")) {
106
+ let message;
107
+ try {
108
+ message = JSON.parse(line);
109
+ } catch {
110
+ continue;
111
+ }
112
+ if (!isObject(message)) continue;
113
+ if (typeof message.progress === "string" && LABEL.test(message.progress)) last = message.progress;
114
+ else if (typeof message.ok === "boolean") response = message;
115
+ }
116
+ if (outcome.timedOut) throw fetchFailure(last, "timeout", ctx);
117
+ if (response?.ok === true) {
118
+ if (isLayers(response.layers)) return response.layers;
119
+ throw fetchFailure(last, "invalid-response", ctx);
120
+ }
121
+ if (response?.ok === false) {
122
+ const match = typeof response.error === "string" ? FAILURE.exec(response.error) : null;
123
+ if (match && LABEL.test(match[1]) && REASON.test(match[2])) throw fetchFailure(match[1], match[2], ctx);
124
+ throw fetchFailure(last, "invalid-response", ctx);
125
+ }
126
+ throw fetchFailure(last, outcome.exitCode === 0 ? "invalid-response" : "crashed", ctx);
127
+ }
128
+ function fetchFailure(label, reason, ctx) {
129
+ if (label === "init") {
130
+ if (/^\d{3}$/.test(reason)) return new PhaseLoaderError(`Phase rejected the token for account "${ctx.account}" at ${ctx.host} (${reason}).`);
131
+ if (reason === "invalid-token") return new PhaseLoaderError(`The Phase token for account "${ctx.account}" has an invalid format.`);
132
+ if (reason === "unreachable" || reason === "timeout") return new PhaseLoaderError(`Phase not reachable at ${ctx.host} (${reason}; tailnet?).`);
133
+ }
134
+ return new PhaseLoaderError(`Phase export failed: ${label} (${reason})${label === "personal /" && ctx.personalSource ? ` Check the personal app from ${ctx.personalSource}.` : ""}`);
135
+ }
136
+ function isLayers(value) {
137
+ return Array.isArray(value) && value.every((layer) => isObject(layer) && typeof layer.label === "string" && LABEL.test(layer.label) && Array.isArray(layer.entries) && layer.entries.every((s) => Array.isArray(s) && s.length === 2 && typeof s[0] === "string" && typeof s[1] === "string"));
138
+ }
139
+ function isObject(value) {
140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
141
+ }
142
+ // src/token.ts
143
+ const KEYRING_SERVICE = "phase";
144
+ /** Env variable that can carry the token of a keyring account: `repo/dev` → `PHASE_TOKEN_REPO_DEV`. */
145
+ function tokenEnvVar(account) {
146
+ return `PHASE_TOKEN_${account.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
147
+ }
148
+ /** Trimmed keyring entry of service `phase`, `null` when there is none. Throws when the keyring is unavailable. */
149
+ function readKeyring(account) {
150
+ return new Entry("phase", account).getPassword()?.trim() || null;
151
+ }
152
+ /** Stores `value` under service `phase`; throws a {@link PhaseLoaderError} without the value when it fails. */
153
+ function writeKeyring(account, value) {
154
+ try {
155
+ new Entry(KEYRING_SERVICE, account).setPassword(value);
156
+ } catch {
157
+ throw new PhaseLoaderError(`Could not write the OS keyring (service "${KEYRING_SERVICE}", account "${account}").`);
158
+ }
159
+ }
160
+ /**
161
+ * Token of a keyring account: the OS keyring (service `phase`) unless `PHASE_TOKEN_SOURCE=env`; the env variable
162
+ * {@link tokenEnvVar} when the keyring is skipped, unavailable or has no entry.
163
+ */
164
+ function readToken(account) {
165
+ const envVar = tokenEnvVar(account);
166
+ let keyring = "keyring skipped (PHASE_TOKEN_SOURCE=env)";
167
+ if (process.env.PHASE_TOKEN_SOURCE !== "env") try {
168
+ const token = readKeyring(account);
169
+ if (token) return token;
170
+ keyring = "no keyring entry";
171
+ } catch {
172
+ keyring = "keyring unavailable";
173
+ }
174
+ const fromEnv = process.env[envVar]?.trim();
175
+ if (fromEnv) return fromEnv;
176
+ throw new PhaseLoaderError(`No Phase token for account "${account}" (${keyring}, ${envVar} unset). Store it in the OS keyring (service "${KEYRING_SERVICE}", account "${account}") with "phase-loader setup", or set ${envVar}.`);
177
+ }
178
+ // src/load.ts
179
+ /** SDK child script, its timeout and extra env; internal, tests point `script` at a fake and pass its config. */
180
+ const fetchChild = {
181
+ script: fileURLToPath(new URL("./fetch-child.js", import.meta.url)),
182
+ timeoutMs: FETCH_TIMEOUT_MS,
183
+ extraEnv: {}
184
+ };
185
+ /** Env the SDK child inherits: PATH, home, Windows root, proxy and CA settings; no token, no other secret. */
186
+ const CHILD_ENV = /^(?:PATH|HOME|USERPROFILE|SYSTEMROOT|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)$/i;
187
+ function childEnv() {
188
+ const env = {};
189
+ for (const [key, value] of Object.entries(process.env)) if (value !== void 0 && CHILD_ENV.test(key)) env[key] = value;
190
+ return {
191
+ ...env,
192
+ ...fetchChild.extraEnv
193
+ };
194
+ }
195
+ function prepare(target, cwd) {
196
+ const config = loadConfig(cwd);
197
+ const t = Object.hasOwn(config.targets, target) ? config.targets[target] : void 0;
198
+ if (!t) throw new PhaseLoaderError(`Unknown target "${target}" (phase.config.json defines: ${Object.keys(config.targets).join(", ")}).`);
199
+ const token = readToken(t.account);
200
+ const personal = personalApp(t);
201
+ return {
202
+ config,
203
+ request: {
204
+ host: config.host,
205
+ token,
206
+ envName: t.env,
207
+ layers: t.layers.map(({ app, appId, path }) => ({
208
+ app,
209
+ appId,
210
+ path
211
+ })),
212
+ ...personal ? { personal: personal.ref } : {}
213
+ },
214
+ ctx: {
215
+ host: config.host,
216
+ account: t.account,
217
+ personalSource: personal?.source
218
+ }
219
+ };
220
+ }
221
+ /** Exports every layer (unmerged, `personal /` last) after the shared child run; used by `example` and `check`. */
222
+ async function fetchLayers(target, cwd) {
223
+ const { config, request, ctx } = prepare(target, cwd);
224
+ const outcome = await fetchInChild(request);
225
+ return {
226
+ config,
227
+ layers: readFetchOutput(outcome.stdout, outcome, ctx)
228
+ };
229
+ }
230
+ /** Merges the layers later-wins, applies `require`, `keep` and the `${` check; shared by the async and sync loader. */
231
+ function merge(target, config, fetched, keep) {
232
+ const exported = {};
233
+ for (const layer of fetched) for (const [key, value] of layer.entries) exported[key] = value;
234
+ const missing = config.require.filter((key) => !Object.hasOwn(exported, key));
235
+ if (missing.length > 0) throw new PhaseLoaderError(`Phase export for "${target}" lacks required key(s): ${missing.join(", ")}. Check the token's access.`);
236
+ const env = {};
237
+ for (const [key, value] of Object.entries(exported)) {
238
+ if (keep?.includes(key) && process.env[key] !== void 0) continue;
239
+ env[key] = value;
240
+ }
241
+ const literal = Object.keys(env).filter((key) => env[key]?.startsWith("${") && !config.allowLiteral.includes(key));
242
+ if (literal.length > 0) throw new PhaseLoaderError(`Unresolved Phase reference in: ${literal.join(", ")}. Grant the token access to the referenced app, or list the key in "allowLiteral" if the value is meant literally.`);
243
+ return {
244
+ env,
245
+ layers: fetched.map((l) => l.label)
246
+ };
247
+ }
248
+ /**
249
+ * Exports the layers of `target` from Phase and returns the merged values without touching `process.env`.
250
+ * Layers merge in order (later wins), the personal app comes last. The SDK runs in a child process (it can throw
251
+ * outside its promises) and resolves `${…}` references; a value that still starts with `${` (unreadable reference)
252
+ * throws unless the key is in `allowLiteral`.
253
+ * `keep`: keys whose existing `process.env` value wins; they are left out of the result.
254
+ */
255
+ async function loadPhaseEnv(target, opts = {}) {
256
+ const { config, layers } = await fetchLayers(target, opts.cwd ?? process.cwd());
257
+ return merge(target, config, layers, opts.keep);
258
+ }
259
+ /**
260
+ * Blocking variant for code that cannot await (e.g. a config module loaded at import time): same child, same
261
+ * checks, but the values are assigned to `process.env` (keys in `keep` that are already set stay untouched).
262
+ * The child's output is captured through pipes, never inherited.
263
+ */
264
+ function loadPhaseEnvSync(target, opts = {}) {
265
+ const { config, request, ctx } = prepare(target, opts.cwd ?? process.cwd());
266
+ const loaded = merge(target, config, readFetchOutput(...fetchSync(request), ctx), opts.keep);
267
+ Object.assign(process.env, loaded.env);
268
+ return loaded;
269
+ }
270
+ function fetchSync(request) {
271
+ try {
272
+ return [execFileSync(process.execPath, [fetchChild.script], {
273
+ input: JSON.stringify(request),
274
+ stdio: [
275
+ "pipe",
276
+ "pipe",
277
+ "pipe"
278
+ ],
279
+ env: childEnv(),
280
+ timeout: fetchChild.timeoutMs,
281
+ killSignal: "SIGKILL",
282
+ encoding: "utf8",
283
+ maxBuffer: 67108864
284
+ }), {
285
+ timedOut: false,
286
+ exitCode: 0
287
+ }];
288
+ } catch (error) {
289
+ const e = error;
290
+ return [e.stdout ?? "", {
291
+ timedOut: e.code === "ETIMEDOUT",
292
+ exitCode: e.status ?? null
293
+ }];
294
+ }
295
+ }
296
+ /**
297
+ * Personal app reference (`PHASE_PERSONAL_APP`, else the keyring account `personal.account`), only for targets with
298
+ * `personal`. When neither is set the layer is skipped with a stderr line.
299
+ */
300
+ function personalApp(t) {
301
+ if (!t.personal) return void 0;
302
+ const fromEnv = process.env.PHASE_PERSONAL_APP?.trim();
303
+ if (fromEnv) return {
304
+ ref: fromEnv,
305
+ source: "PHASE_PERSONAL_APP"
306
+ };
307
+ const account = t.personal.account;
308
+ let reason;
309
+ try {
310
+ const ref = readKeyring(account);
311
+ if (ref) return {
312
+ ref,
313
+ source: `keyring account "${account}"`
314
+ };
315
+ reason = `no keyring entry for account "${account}" and PHASE_PERSONAL_APP unset`;
316
+ } catch {
317
+ reason = `keyring unavailable for account "${account}" and PHASE_PERSONAL_APP unset`;
318
+ }
319
+ process.stderr.write(`[env] personal layer skipped: ${reason}\n`);
320
+ }
321
+ /** Runs the SDK child: request (with the token) on stdin, messages on stdout, stderr drained and discarded. */
322
+ function fetchInChild(request) {
323
+ return new Promise((resolve) => {
324
+ let stdout = "";
325
+ let timedOut = false;
326
+ let timer;
327
+ let settled = false;
328
+ const done = (exitCode) => {
329
+ if (settled) return;
330
+ settled = true;
331
+ clearTimeout(timer);
332
+ resolve({
333
+ stdout,
334
+ timedOut,
335
+ exitCode
336
+ });
337
+ };
338
+ let child;
339
+ try {
340
+ child = spawn(process.execPath, [fetchChild.script], {
341
+ stdio: [
342
+ "pipe",
343
+ "pipe",
344
+ "pipe"
345
+ ],
346
+ env: childEnv()
347
+ });
348
+ } catch {
349
+ done(null);
350
+ return;
351
+ }
352
+ child.stdout?.setEncoding("utf8");
353
+ child.stdout?.on("data", (chunk) => {
354
+ stdout += chunk;
355
+ });
356
+ child.stderr?.resume();
357
+ child.stdin?.on("error", () => {});
358
+ child.once("error", () => done(null));
359
+ child.once("close", (code) => done(code));
360
+ timer = setTimeout(() => {
361
+ timedOut = true;
362
+ child.kill("SIGKILL");
363
+ }, fetchChild.timeoutMs);
364
+ child.stdin?.end(JSON.stringify(request));
365
+ });
366
+ }
367
+ // src/run.ts
368
+ const FORWARDED = ["SIGINT", "SIGTERM"];
369
+ /**
370
+ * Loads `target` and runs `argv` (no `shell: true`; cross-spawn resolves Windows `.cmd` shims) with `process.env` + the Phase values + `PHASE_ENV_LOADED=<target>`.
371
+ * SIGINT/SIGTERM are forwarded to the child; resolves with its exit code (128 + signal number when killed).
372
+ */
373
+ async function run(target, argv) {
374
+ const [command, ...args] = argv;
375
+ if (!command) throw new PhaseLoaderError("No command given: phase-loader run <target> -- <command> [args...]");
376
+ const { env, layers } = await loadPhaseEnv(target);
377
+ process.stderr.write(`[env] loaded ${Object.keys(env).length} keys from Phase (${target}: ${layers.join(" + ")})\n`);
378
+ let child;
379
+ try {
380
+ child = spawn$1(command, args, {
381
+ stdio: "inherit",
382
+ env: {
383
+ ...process.env,
384
+ ...env,
385
+ PHASE_ENV_LOADED: target
386
+ }
387
+ });
388
+ } catch (error) {
389
+ throw new PhaseLoaderError(`Could not start "${command}" (${error.code ?? "error"}).`);
390
+ }
391
+ const handlers = FORWARDED.map((signal) => [signal, () => child.kill(signal)]);
392
+ for (const [signal, handler] of handlers) process.on(signal, handler);
393
+ const detach = () => {
394
+ for (const [signal, handler] of handlers) process.off(signal, handler);
395
+ };
396
+ return new Promise((resolve, reject) => {
397
+ child.once("error", (error) => {
398
+ detach();
399
+ reject(new PhaseLoaderError(`Could not start "${command}" (${error.code ?? "error"}).`));
400
+ });
401
+ child.once("exit", (code, signal) => {
402
+ detach();
403
+ resolve(code ?? 128 + (signal ? constants.signals[signal] : 0));
404
+ });
405
+ });
406
+ }
407
+ export { KEYRING_SERVICE as a, loadConfig as c, loadPhaseEnvSync as i, PhaseLoaderError as l, fetchLayers as n, readToken as o, loadPhaseEnv as r, writeKeyring as s, run as t };
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@philkrpp/phase-loader",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Load env values from a self-hosted Phase secrets manager into a child process or the current process, with layered apps/paths and OS keyring tokens.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/philkrpp/phase-loader.git"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "bin": {
15
+ "phase-loader": "dist/cli.js"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "prepack": "tsdown",
29
+ "test": "vitest run",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@napi-rs/keyring": "2.1.2",
34
+ "@phase.dev/phase-node": "3.2.0",
35
+ "cross-spawn": "7.0.6"
36
+ },
37
+ "devDependencies": {
38
+ "@types/cross-spawn": "6.0.6",
39
+ "@types/node": "22.20.5",
40
+ "tsdown": "0.23.1",
41
+ "typescript": "6.0.3",
42
+ "vitest": "5.0.5"
43
+ }
44
+ }