envpass-cli 0.0.0-stage → 0.1.6

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 CHANGED
@@ -1,3 +1,45 @@
1
- # Temporary Holding Version
1
+ # envpass-cli
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
+ Client library and CLI for [envpass](https://envpass.dev): encrypted environment variables for every project, service and environment.
4
+
5
+ ## CLI
6
+
7
+ ```bash
8
+ bunx envpass-cli login # sign in through your browser
9
+ bunx envpass-cli link -w acme -p shop -e dev # save defaults to .envpass.json (no secrets inside)
10
+ bunx envpass-cli pull # write .env
11
+ bunx envpass-cli pull -e prod -o - # print to stdout
12
+ bunx envpass-cli run -e dev -- bun dev # run with values injected, masked in output
13
+ ```
14
+
15
+ `npx envpass-cli …` works the same way.
16
+
17
+ In CI, use a service token instead of `login`:
18
+
19
+ ```bash
20
+ ENVPASS_TOKEN=ep_live_… npx envpass-cli pull -e prod
21
+ ```
22
+
23
+ | Option | Environment variable | |
24
+ |---|---|---|
25
+ | `-w, --workspace` | `ENVPASS_WORKSPACE` | Workspace slug (not needed with a service token) |
26
+ | `-p, --project` | `ENVPASS_PROJECT` | Project slug (not needed with a service token) |
27
+ | `-e, --env` | `ENVPASS_ENV` | Environment slug |
28
+ | `-s, --service` | `ENVPASS_SERVICE` | Service slug, or `shared` |
29
+ | `--url` | `ENVPASS_URL` | Server, default `https://envpass.dev` |
30
+ | | `ENVPASS_TOKEN` | Service token or CLI session token |
31
+
32
+ ## Library
33
+
34
+ ```ts
35
+ import { createEnvpass, loadEnv } from "envpass-cli"
36
+
37
+ await loadEnv({ environment: "prod" })
38
+
39
+ const envpass = createEnvpass({ token: process.env.ENVPASS_TOKEN })
40
+ const entries = await envpass.resolve({ environment: "prod", service: "api" })
41
+
42
+ const { data } = await envpass.api.GET("/api/v1/workspaces")
43
+ ```
44
+
45
+ `envpass.api` is a fully typed client for the whole REST API, generated from the OpenAPI spec.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { writeFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { parseArgs } from "node:util";
6
+ import { createEnvpass, EnvpassError } from "./client.js";
7
+ import { baseUrl, forgetToken, resolveScope, saveToken, storedToken, writeProjectLink, } from "./config.js";
8
+ import { deviceLogin, openBrowser } from "./device.js";
9
+ import { maskingStream } from "./mask.js";
10
+ const { version } = createRequire(import.meta.url)("../package.json");
11
+ const HELP = `envpass ${version}: encrypted environment variables for every project, service and environment
12
+
13
+ Usage
14
+ envpass login Sign in through your browser
15
+ envpass logout Forget the saved login
16
+ envpass whoami Show who the current token belongs to
17
+ envpass link -w <ws> -p <project> Save defaults to .envpass.json in this directory
18
+ envpass pull [-e env] [-o .env] Write resolved values to a file ("-" for stdout)
19
+ envpass run [-e env] -- <command> Run a command with the values injected
20
+
21
+ Options
22
+ -w, --workspace <slug> Workspace (not needed with a service token)
23
+ -p, --project <slug> Project (not needed with a service token)
24
+ -e, --env <slug> Environment, e.g. dev or prod
25
+ -s, --service <slug> Service layer, or "shared"
26
+ -o, --out <file> pull: output file (default .env)
27
+ -f, --format <format> pull: dotenv, shell or json (default dotenv)
28
+ --no-mask run: show secret values in the command's output
29
+ --no-browser login: print the link instead of opening a browser
30
+ --url <url> envpass server (default https://envpass.dev, or ENVPASS_URL)
31
+
32
+ Environment
33
+ ENVPASS_TOKEN A service token (ep_live_…) or CLI session token
34
+ ENVPASS_WORKSPACE, ENVPASS_PROJECT, ENVPASS_ENV, ENVPASS_SERVICE
35
+ `;
36
+ function fail(message, code = 1) {
37
+ process.stderr.write(`envpass: ${message}\n`);
38
+ process.exit(code);
39
+ }
40
+ async function main(argv) {
41
+ const { values, positionals } = parseArgs({
42
+ args: argv,
43
+ allowPositionals: true,
44
+ options: {
45
+ workspace: { type: "string", short: "w" },
46
+ project: { type: "string", short: "p" },
47
+ env: { type: "string", short: "e" },
48
+ service: { type: "string", short: "s" },
49
+ out: { type: "string", short: "o" },
50
+ format: { type: "string", short: "f" },
51
+ url: { type: "string" },
52
+ "no-mask": { type: "boolean" },
53
+ "no-browser": { type: "boolean" },
54
+ help: { type: "boolean", short: "h" },
55
+ version: { type: "boolean", short: "v" },
56
+ },
57
+ });
58
+ const [command, ...rest] = positionals;
59
+ if (values.version)
60
+ return void process.stdout.write(`${version}\n`);
61
+ if (values.help || !command || command === "help")
62
+ return void process.stdout.write(HELP);
63
+ const url = baseUrl(values.url);
64
+ const flags = {
65
+ workspace: values.workspace,
66
+ project: values.project,
67
+ environment: values.env,
68
+ service: values.service,
69
+ };
70
+ switch (command) {
71
+ case "login": {
72
+ const token = await deviceLogin(url, (code) => {
73
+ const link = code.verification_uri_complete ?? code.verification_uri;
74
+ process.stderr.write(`\nOpen ${link}\nand confirm the code: ${code.user_code}\n\nWaiting for approval…\n`);
75
+ if (!values["no-browser"])
76
+ openBrowser(link);
77
+ });
78
+ const path = saveToken(url, token);
79
+ const me = await createEnvpass({ baseUrl: url, token }).me();
80
+ process.stderr.write(`Signed in. Token saved to ${path}\n`);
81
+ if (me.workspaces.length) {
82
+ process.stderr.write(`Workspaces: ${me.workspaces.map((w) => w.slug).join(", ")}\n`);
83
+ }
84
+ return;
85
+ }
86
+ case "logout": {
87
+ const token = storedToken(url);
88
+ if (token) {
89
+ await fetch(`${url}/api/auth/sign-out`, {
90
+ method: "POST",
91
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
92
+ body: "{}",
93
+ }).catch(() => undefined);
94
+ }
95
+ const removed = forgetToken(url);
96
+ process.stderr.write(removed ? `Signed out of ${url}\n` : `No saved login for ${url}\n`);
97
+ return;
98
+ }
99
+ case "whoami": {
100
+ const client = createEnvpass({ baseUrl: url });
101
+ if (!client.hasToken)
102
+ fail("Not signed in. Run `envpass login` or set ENVPASS_TOKEN.");
103
+ const me = await client.me();
104
+ if (me.token) {
105
+ process.stdout.write(`Service token ${me.token.id} (${me.token.access}) for project ${me.token.projectId}\n`);
106
+ }
107
+ else {
108
+ process.stdout.write(`User ${me.userId}\n`);
109
+ for (const ws of me.workspaces)
110
+ process.stdout.write(` ${ws.slug}${ws.isOwner ? " (owner)" : ""}\n`);
111
+ }
112
+ return;
113
+ }
114
+ case "link": {
115
+ const scope = resolveScope(flags);
116
+ if (!scope.workspace || !scope.project)
117
+ fail("Pass --workspace and --project.");
118
+ const path = writeProjectLink(scope);
119
+ process.stderr.write(`Saved ${path}. It holds no secrets and is safe to commit.\n`);
120
+ return;
121
+ }
122
+ case "pull": {
123
+ const format = (values.format ?? "dotenv");
124
+ if (!["dotenv", "shell", "json"].includes(format))
125
+ fail("--format must be dotenv, shell or json");
126
+ const client = createEnvpass({ baseUrl: url });
127
+ if (!client.hasToken)
128
+ fail("Not signed in. Run `envpass login` or set ENVPASS_TOKEN.");
129
+ const text = format === "json"
130
+ ? `${JSON.stringify(Object.fromEntries((await client.resolve(flags)).map((e) => [e.key, e.value])), null, 2)}\n`
131
+ : await client.resolveText(flags, format);
132
+ const out = values.out ?? ".env";
133
+ if (out === "-")
134
+ return void process.stdout.write(text);
135
+ writeFileSync(out, text, { mode: 0o600 });
136
+ const count = text
137
+ .split("\n")
138
+ .filter((line) => line.includes("=") || line.includes(":")).length;
139
+ process.stderr.write(`Wrote ${out} (${count} values). Keep it out of git.\n`);
140
+ return;
141
+ }
142
+ case "run": {
143
+ const separator = argv.indexOf("--");
144
+ const commandArgs = separator === -1 ? rest : argv.slice(separator + 1);
145
+ const [program, ...programArgs] = commandArgs;
146
+ if (!program)
147
+ fail("Usage: envpass run [-e env] -- <command> [args…]");
148
+ const client = createEnvpass({ baseUrl: url });
149
+ if (!client.hasToken)
150
+ fail("Not signed in. Run `envpass login` or set ENVPASS_TOKEN.");
151
+ const entries = await client.resolve(flags);
152
+ const env = { ...process.env, ...Object.fromEntries(entries.map((e) => [e.key, e.value])) };
153
+ const mask = !values["no-mask"];
154
+ const child = spawn(program, programArgs, {
155
+ env,
156
+ stdio: mask ? ["inherit", "pipe", "pipe"] : "inherit",
157
+ });
158
+ if (mask) {
159
+ const secrets = entries.map((e) => e.value);
160
+ child.stdout?.pipe(maskingStream(secrets)).pipe(process.stdout);
161
+ child.stderr?.pipe(maskingStream(secrets)).pipe(process.stderr);
162
+ }
163
+ for (const signal of ["SIGINT", "SIGTERM"]) {
164
+ process.on(signal, () => child.kill(signal));
165
+ }
166
+ child.on("error", (error) => fail(`Could not start ${program}: ${error.message}`, 127));
167
+ child.on("exit", (code, signal) => {
168
+ if (signal)
169
+ process.kill(process.pid, signal);
170
+ else
171
+ process.exit(code ?? 0);
172
+ });
173
+ return;
174
+ }
175
+ default:
176
+ fail(`Unknown command "${command}". Run \`envpass --help\`.`);
177
+ }
178
+ }
179
+ main(process.argv.slice(2)).catch((error) => {
180
+ if (error instanceof EnvpassError)
181
+ fail(`${error.message}${error.code ? ` (${error.code})` : ""}`);
182
+ fail(error instanceof Error ? error.message : String(error));
183
+ });
@@ -0,0 +1,49 @@
1
+ import { type ProjectLink } from "./config.js";
2
+ import type { components, paths } from "./schema.js";
3
+ export type Source = components["schemas"]["SecretSource"];
4
+ export type ResolvedEntry = {
5
+ key: string;
6
+ value: string;
7
+ source: Source;
8
+ };
9
+ export type ExportFormat = "dotenv" | "json" | "shell";
10
+ export type EnvpassOptions = {
11
+ baseUrl?: string;
12
+ token?: string;
13
+ userAgent?: string;
14
+ };
15
+ export declare class EnvpassError extends Error {
16
+ readonly status: number;
17
+ readonly code?: string | undefined;
18
+ constructor(message: string, status: number, code?: string | undefined);
19
+ }
20
+ export declare function createEnvpass(options?: EnvpassOptions): {
21
+ url: string;
22
+ hasToken: boolean;
23
+ api: import("openapi-fetch").Client<paths, `${string}/${string}`>;
24
+ me(): Promise<{
25
+ actor: "user" | "token";
26
+ userId: string;
27
+ token: {
28
+ id: string;
29
+ workspaceId: string;
30
+ projectId: string;
31
+ serviceId: string | null;
32
+ access: "read" | "write";
33
+ } | null;
34
+ workspaces: {
35
+ id: string;
36
+ name: string;
37
+ slug: string;
38
+ isOwner: boolean;
39
+ }[];
40
+ }>;
41
+ resolve(scope?: ProjectLink): Promise<ResolvedEntry[]>;
42
+ resolveText(scope?: ProjectLink, format?: Exclude<ExportFormat, "json">): Promise<string>;
43
+ };
44
+ export type Envpass = ReturnType<typeof createEnvpass>;
45
+ export declare function loadEnv(scope?: ProjectLink & EnvpassOptions & {
46
+ override?: boolean;
47
+ }): Promise<{
48
+ [k: string]: string;
49
+ }>;
package/dist/client.js ADDED
@@ -0,0 +1,81 @@
1
+ import createFetchClient from "openapi-fetch";
2
+ import { baseUrl, resolveScope, resolveToken } from "./config.js";
3
+ export class EnvpassError extends Error {
4
+ status;
5
+ code;
6
+ constructor(message, status, code) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ this.name = "EnvpassError";
11
+ }
12
+ }
13
+ function errorFrom(status, body) {
14
+ const error = body?.error;
15
+ return new EnvpassError(error?.message ?? `Request failed with status ${status}`, status, error?.code);
16
+ }
17
+ export function createEnvpass(options = {}) {
18
+ const url = baseUrl(options.baseUrl);
19
+ const token = resolveToken(url, options.token);
20
+ const headers = { "user-agent": options.userAgent ?? "envpass-node" };
21
+ if (token)
22
+ headers.authorization = `Bearer ${token}`;
23
+ const api = createFetchClient({ baseUrl: url, headers });
24
+ function scopeQuery(scope) {
25
+ if (!scope.environment) {
26
+ throw new EnvpassError("No environment. Pass --env, set ENVPASS_ENV, or run `envpass link`.", 400, "invalid");
27
+ }
28
+ return {
29
+ workspace: scope.workspace,
30
+ project: scope.project,
31
+ environment: scope.environment,
32
+ service: scope.service,
33
+ };
34
+ }
35
+ return {
36
+ url,
37
+ hasToken: Boolean(token),
38
+ api,
39
+ async me() {
40
+ const { data, error, response } = await api.GET("/api/v1/me");
41
+ if (!data)
42
+ throw errorFrom(response.status, error);
43
+ return data;
44
+ },
45
+ async resolve(scope = {}) {
46
+ const query = scopeQuery(resolveScope(scope));
47
+ const { data, error, response } = await api.GET("/api/v1/resolve", {
48
+ params: { query: { ...query, format: "json" } },
49
+ });
50
+ if (!data || typeof data === "string")
51
+ throw errorFrom(response.status, error);
52
+ return data.entries;
53
+ },
54
+ async resolveText(scope = {}, format = "dotenv") {
55
+ const query = scopeQuery(resolveScope(scope));
56
+ const response = await fetch(`${url}/api/v1/resolve?${new URLSearchParams(Object.entries({ ...query, format }).filter((entry) => Boolean(entry[1])))}`, { headers });
57
+ const text = await response.text();
58
+ if (!response.ok) {
59
+ let body;
60
+ try {
61
+ body = JSON.parse(text);
62
+ }
63
+ catch {
64
+ body = undefined;
65
+ }
66
+ throw errorFrom(response.status, body);
67
+ }
68
+ return text;
69
+ },
70
+ };
71
+ }
72
+ export async function loadEnv(scope = {}) {
73
+ const { baseUrl: url, token, userAgent, override = false, ...link } = scope;
74
+ const client = createEnvpass({ baseUrl: url, token, userAgent });
75
+ const entries = await client.resolve(link);
76
+ for (const entry of entries) {
77
+ if (override || process.env[entry.key] === undefined)
78
+ process.env[entry.key] = entry.value;
79
+ }
80
+ return Object.fromEntries(entries.map((entry) => [entry.key, entry.value]));
81
+ }
@@ -0,0 +1,14 @@
1
+ export declare const DEFAULT_URL = "https://envpass.dev";
2
+ export type ProjectLink = {
3
+ workspace?: string;
4
+ project?: string;
5
+ environment?: string;
6
+ service?: string;
7
+ };
8
+ export declare function baseUrl(explicit?: string): string;
9
+ export declare function saveToken(url: string, token: string): string;
10
+ export declare function forgetToken(url: string): boolean;
11
+ export declare function storedToken(url: string): string | undefined;
12
+ export declare function resolveToken(url: string, explicit?: string): string | undefined;
13
+ export declare function writeProjectLink(link: ProjectLink, dir?: string): string;
14
+ export declare function resolveScope(flags: ProjectLink, start?: string): ProjectLink;
package/dist/config.js ADDED
@@ -0,0 +1,87 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ export const DEFAULT_URL = "https://envpass.dev";
5
+ const PROJECT_FILE = ".envpass.json";
6
+ export function baseUrl(explicit) {
7
+ return (explicit ?? process.env.ENVPASS_URL ?? DEFAULT_URL).replace(/\/+$/, "");
8
+ }
9
+ function credentialsPath() {
10
+ const root = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
11
+ return join(root, "envpass", "credentials.json");
12
+ }
13
+ function readCredentials() {
14
+ const path = credentialsPath();
15
+ if (!existsSync(path))
16
+ return {};
17
+ try {
18
+ return JSON.parse(readFileSync(path, "utf8"));
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ }
24
+ export function saveToken(url, token) {
25
+ const path = credentialsPath();
26
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
27
+ const credentials = readCredentials();
28
+ credentials[url] = { token, savedAt: new Date().toISOString() };
29
+ writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
30
+ chmodSync(path, 0o600);
31
+ return path;
32
+ }
33
+ export function forgetToken(url) {
34
+ const path = credentialsPath();
35
+ const credentials = readCredentials();
36
+ if (!credentials[url])
37
+ return false;
38
+ delete credentials[url];
39
+ if (Object.keys(credentials).length === 0)
40
+ rmSync(path, { force: true });
41
+ else
42
+ writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
43
+ return true;
44
+ }
45
+ export function storedToken(url) {
46
+ return readCredentials()[url]?.token;
47
+ }
48
+ export function resolveToken(url, explicit) {
49
+ return explicit ?? process.env.ENVPASS_TOKEN ?? storedToken(url);
50
+ }
51
+ function findProjectFile(start = process.cwd()) {
52
+ let dir = resolve(start);
53
+ for (;;) {
54
+ const candidate = join(dir, PROJECT_FILE);
55
+ if (existsSync(candidate))
56
+ return candidate;
57
+ const parent = dirname(dir);
58
+ if (parent === dir)
59
+ return undefined;
60
+ dir = parent;
61
+ }
62
+ }
63
+ function readProjectLink(start) {
64
+ const path = findProjectFile(start);
65
+ if (!path)
66
+ return {};
67
+ try {
68
+ return JSON.parse(readFileSync(path, "utf8"));
69
+ }
70
+ catch {
71
+ throw new Error(`${path} is not valid JSON`);
72
+ }
73
+ }
74
+ export function writeProjectLink(link, dir = process.cwd()) {
75
+ const path = join(dir, PROJECT_FILE);
76
+ writeFileSync(path, `${JSON.stringify(link, null, 2)}\n`);
77
+ return path;
78
+ }
79
+ export function resolveScope(flags, start) {
80
+ const file = readProjectLink(start);
81
+ return {
82
+ workspace: flags.workspace ?? process.env.ENVPASS_WORKSPACE ?? file.workspace,
83
+ project: flags.project ?? process.env.ENVPASS_PROJECT ?? file.project,
84
+ environment: flags.environment ?? process.env.ENVPASS_ENV ?? file.environment,
85
+ service: flags.service ?? process.env.ENVPASS_SERVICE ?? file.service,
86
+ };
87
+ }
@@ -0,0 +1,13 @@
1
+ type DeviceCode = {
2
+ device_code: string;
3
+ user_code: string;
4
+ verification_uri: string;
5
+ verification_uri_complete?: string;
6
+ expires_in: number;
7
+ interval: number;
8
+ };
9
+ export declare function openBrowser(url: string): void;
10
+ export declare function deviceLogin(url: string, onCode: (code: DeviceCode) => void, options?: {
11
+ fetch?: typeof fetch;
12
+ }): Promise<string>;
13
+ export {};
package/dist/device.js ADDED
@@ -0,0 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+ const CLI_CLIENT_ID = "envpass-cli";
3
+ export function openBrowser(url) {
4
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
5
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
6
+ try {
7
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
8
+ child.on("error", () => { });
9
+ child.unref();
10
+ }
11
+ catch {
12
+ return;
13
+ }
14
+ }
15
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ export async function deviceLogin(url, onCode, options = {}) {
17
+ const http = options.fetch ?? fetch;
18
+ const start = await http(`${url}/api/auth/device/code`, {
19
+ method: "POST",
20
+ headers: { "content-type": "application/json" },
21
+ body: JSON.stringify({ client_id: CLI_CLIENT_ID }),
22
+ });
23
+ if (!start.ok)
24
+ throw new Error(`Could not start login (${start.status})`);
25
+ const code = (await start.json());
26
+ onCode(code);
27
+ let interval = Math.max(1, code.interval) * 1000;
28
+ const deadline = Date.now() + code.expires_in * 1000;
29
+ while (Date.now() < deadline) {
30
+ await sleep(interval);
31
+ const poll = await http(`${url}/api/auth/device/token`, {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json" },
34
+ body: JSON.stringify({
35
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
36
+ device_code: code.device_code,
37
+ client_id: CLI_CLIENT_ID,
38
+ }),
39
+ });
40
+ const body = (await poll.json());
41
+ if (body.access_token)
42
+ return body.access_token;
43
+ if (body.error === "authorization_pending")
44
+ continue;
45
+ if (body.error === "slow_down") {
46
+ interval += 5000;
47
+ continue;
48
+ }
49
+ if (body.error === "access_denied")
50
+ throw new Error("Login was denied in the browser.");
51
+ if (body.error === "expired_token")
52
+ throw new Error("The login code expired. Run `envpass login` again.");
53
+ throw new Error(body.error_description ?? body.error ?? `Login failed (${poll.status})`);
54
+ }
55
+ throw new Error("The login code expired. Run `envpass login` again.");
56
+ }
@@ -0,0 +1,4 @@
1
+ export { createEnvpass, type Envpass, EnvpassError, type EnvpassOptions, type ExportFormat, loadEnv, type ResolvedEntry, type Source, } from "./client.js";
2
+ export { DEFAULT_URL, type ProjectLink } from "./config.js";
3
+ export { maskText } from "./mask.js";
4
+ export type { components, paths } from "./schema.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createEnvpass, EnvpassError, loadEnv, } from "./client.js";
2
+ export { DEFAULT_URL } from "./config.js";
3
+ export { maskText } from "./mask.js";
package/dist/mask.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Transform } from "node:stream";
2
+ export declare function maskText(text: string, secrets: readonly string[]): string;
3
+ export declare function maskingStream(values: readonly string[]): Transform;
package/dist/mask.js ADDED
@@ -0,0 +1,59 @@
1
+ import { Transform } from "node:stream";
2
+ const MASK = "***";
3
+ const MIN_LENGTH = 4;
4
+ export function maskText(text, secrets) {
5
+ let out = text;
6
+ for (const secret of secrets) {
7
+ if (secret.length >= MIN_LENGTH)
8
+ out = out.split(secret).join(MASK);
9
+ }
10
+ return out;
11
+ }
12
+ function partialSuffix(text, secrets) {
13
+ let longest = 0;
14
+ for (const secret of secrets) {
15
+ const max = Math.min(secret.length - 1, text.length);
16
+ for (let k = max; k > longest; k--) {
17
+ if (text.endsWith(secret.slice(0, k))) {
18
+ longest = k;
19
+ break;
20
+ }
21
+ }
22
+ }
23
+ return longest;
24
+ }
25
+ export function maskingStream(values) {
26
+ const secrets = [...new Set(values.filter((v) => v.length >= MIN_LENGTH))].sort((a, b) => b.length - a.length);
27
+ let pending = "";
28
+ let timer;
29
+ const stream = new Transform({
30
+ transform(chunk, _encoding, callback) {
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ pending = maskText(pending + chunk.toString("utf8"), secrets);
34
+ const keep = partialSuffix(pending, secrets);
35
+ const emit = pending.slice(0, pending.length - keep);
36
+ pending = pending.slice(emit.length);
37
+ if (emit)
38
+ this.push(emit);
39
+ if (pending) {
40
+ timer = setTimeout(() => {
41
+ const rest = pending;
42
+ pending = "";
43
+ if (rest)
44
+ stream.push(rest);
45
+ }, 100);
46
+ }
47
+ callback();
48
+ },
49
+ flush(callback) {
50
+ if (timer)
51
+ clearTimeout(timer);
52
+ if (pending)
53
+ this.push(pending);
54
+ pending = "";
55
+ callback();
56
+ },
57
+ });
58
+ return stream;
59
+ }