@theholocron/kyber 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 +21 -0
- package/README.md +43 -2
- package/dist/flags.d.mts +38 -0
- package/dist/flags.mjs +56 -0
- package/dist/non-interactive-error-Mm_Mn79M.mjs +12 -0
- package/dist/notifier.d.mts +33 -0
- package/dist/notifier.mjs +121 -0
- package/dist/prompts.d.mts +214 -0
- package/dist/prompts.mjs +263 -0
- package/dist/secrets.d.mts +191 -0
- package/dist/secrets.mjs +329 -0
- package/dist/testing.d.mts +83 -0
- package/dist/testing.mjs +139 -0
- package/dist/token-args-BgrCq_kK.mjs +38 -0
- package/dist/token-args.type-arNIDKsQ.d.mts +7 -0
- package/dist/ui.d.mts +151 -0
- package/dist/ui.mjs +391 -0
- package/package.json +126 -3
package/dist/secrets.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { n as parseTokenArgs, t as TokenParseError } from "./token-args-BgrCq_kK.mjs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { parse } from "dotenv";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
// src/secrets/copy-to-clipboard/copy-to-clipboard.ts
|
|
9
|
+
/** Puts `text` on the system clipboard through `clipboardy`, which uses the platform's own tool. Loaded only when called. */
|
|
10
|
+
async function copyToClipboard(text) {
|
|
11
|
+
const { default: clipboard } = await import("clipboardy");
|
|
12
|
+
await clipboard.write(text);
|
|
13
|
+
}
|
|
14
|
+
// src/secrets/credential-store/credential-store.ts
|
|
15
|
+
/**
|
|
16
|
+
* The variables the `credentials` providers returned, kept for the life of the process.
|
|
17
|
+
*
|
|
18
|
+
* The plugin loader reads each provider once (`setCredentials`), and the token resolvers look a token up here
|
|
19
|
+
* (`getCredential`) after the flag and the process environment and before the keyring. It is in memory only: the
|
|
20
|
+
* values were already in this process when the provider returned them, and nothing here writes them anywhere.
|
|
21
|
+
*
|
|
22
|
+
* A source is one provider's answer for one environment. They are kept in the order they were set, which is the
|
|
23
|
+
* order `holocron.config` lists the providers, and the first source that has a value wins.
|
|
24
|
+
*/
|
|
25
|
+
const sources = /* @__PURE__ */ new Map();
|
|
26
|
+
/** Remember `variables` as what the source `source` returned. Setting a source again replaces its variables. */
|
|
27
|
+
function setCredentials(source, variables) {
|
|
28
|
+
sources.set(source, variables);
|
|
29
|
+
}
|
|
30
|
+
/** Whether the source `source` has been read already, so a second loader in the same process does not read it again. */
|
|
31
|
+
function hasCredentials(source) {
|
|
32
|
+
return sources.has(source);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The first non-empty value for any of `names`, from the first source that has one. Sources are tried in the order
|
|
36
|
+
* they were set; within a source the names are tried in the order given (the project's own variable name before the
|
|
37
|
+
* vendor's). `undefined` when no source has one.
|
|
38
|
+
*/
|
|
39
|
+
function getCredential(...names) {
|
|
40
|
+
for (const variables of sources.values()) for (const name of names) {
|
|
41
|
+
const value = variables[name];
|
|
42
|
+
if (value) return value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Forget every source. For tests, and for a process that wants the next load to read the providers again. */
|
|
46
|
+
function clearCredentials() {
|
|
47
|
+
sources.clear();
|
|
48
|
+
}
|
|
49
|
+
// src/secrets/private-file/private-file.ts
|
|
50
|
+
/**
|
|
51
|
+
* Create `path` as a new file only its owner can read (mode `0600`, set again on the open file so a loose `umask`
|
|
52
|
+
* cannot widen it), write `text` to it and return which file it is, with its descriptor left open. It fails if the path
|
|
53
|
+
* exists, a symlink included, so nothing is written through a planted link or over a file that is there. When the
|
|
54
|
+
* write fails after the file exists, it closes and removes that file, so no plaintext is left behind. `write` is how the
|
|
55
|
+
* text is written; it is a parameter so a test can make the write fail.
|
|
56
|
+
*/
|
|
57
|
+
function createPrivateFile(path, text, write = writeSync) {
|
|
58
|
+
const fd = openSync(path, "wx", 384);
|
|
59
|
+
try {
|
|
60
|
+
write(fd, text);
|
|
61
|
+
fchmodSync(fd, 384);
|
|
62
|
+
const { dev, ino } = fstatSync(fd);
|
|
63
|
+
return {
|
|
64
|
+
dev,
|
|
65
|
+
ino,
|
|
66
|
+
fd
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(path);
|
|
72
|
+
} catch {}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// src/secrets/shred-file/shred-file.ts
|
|
77
|
+
/**
|
|
78
|
+
* Overwrite the file `identity` names with zeros, close it, and remove it from `path`, so the tokens it held are not
|
|
79
|
+
* left in a deleted-but-recoverable file.
|
|
80
|
+
*
|
|
81
|
+
* The overwrite goes through the descriptor held since the file was written, not through the path, so it zeroes that
|
|
82
|
+
* file wherever the path points now and never follows a symlink: a symlink or another file swapped in is not touched.
|
|
83
|
+
* The path itself is removed only if it is still that file (same device and inode, which the held descriptor keeps
|
|
84
|
+
* from being reused); otherwise it is left alone (`replaced`), so a file another process put there, or another
|
|
85
|
+
* export's file, survives. A path that is already gone is `missing`, not an error; any other failure to remove it
|
|
86
|
+
* throws, so the caller never reports a file as deleted while it is still there.
|
|
87
|
+
*
|
|
88
|
+
* The overwrite is best effort: a journaling or copy-on-write filesystem and an SSD may keep older blocks it does not
|
|
89
|
+
* reach, which is why the file is also kept private and short-lived. One that fails does not stop the removal.
|
|
90
|
+
* `remove` is how the path is deleted; it is a parameter so a test can look at the file just before it goes.
|
|
91
|
+
*/
|
|
92
|
+
function shredFile(path, identity, remove = unlinkSync) {
|
|
93
|
+
let missing = false;
|
|
94
|
+
let same = false;
|
|
95
|
+
try {
|
|
96
|
+
try {
|
|
97
|
+
const { size } = fstatSync(identity.fd);
|
|
98
|
+
writeSync(identity.fd, Buffer.alloc(size), 0, size, 0);
|
|
99
|
+
fsyncSync(identity.fd);
|
|
100
|
+
} catch {}
|
|
101
|
+
const stat = lstatSync(path);
|
|
102
|
+
same = stat.isFile() && stat.dev === identity.dev && stat.ino === identity.ino;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.code !== "ENOENT") throw err;
|
|
105
|
+
missing = true;
|
|
106
|
+
} finally {
|
|
107
|
+
try {
|
|
108
|
+
closeSync(identity.fd);
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
if (missing) return "missing";
|
|
112
|
+
if (!same) return "replaced";
|
|
113
|
+
remove(path);
|
|
114
|
+
return "deleted";
|
|
115
|
+
}
|
|
116
|
+
// src/secrets/credentials-file/credentials-file.ts
|
|
117
|
+
/**
|
|
118
|
+
* Write `text` to `path` as a file only its owner can read (mode `0600`), and return which file that is, with its
|
|
119
|
+
* descriptor still open, so cleanup can remove only it. Without `force` it will not replace a file that is already
|
|
120
|
+
* there. A write that fails removes the file it had just created, so a failed write never leaves plaintext tokens behind.
|
|
121
|
+
*
|
|
122
|
+
* With `force` the replacement is written to a temporary file beside `path` first and then renamed over it, which is
|
|
123
|
+
* atomic, so a replacement that cannot be written or swapped in leaves the old file exactly as it was. Only once the
|
|
124
|
+
* new file is in place is the old file's content overwritten with zeros through a descriptor opened on it before the
|
|
125
|
+
* swap, so the tokens of an earlier run are not left recoverable. A symlink at `path` is replaced, not followed.
|
|
126
|
+
*
|
|
127
|
+
* Windows does not honour these modes, so the caller does not use this there. `write` is how the text is written; it is
|
|
128
|
+
* a parameter so a test can make the write fail after the file exists.
|
|
129
|
+
*/
|
|
130
|
+
function writeCredentialsFile(path, text, force = false, write = writeSync) {
|
|
131
|
+
if (!force) return createPrivateFile(path, text, write);
|
|
132
|
+
const old = openExisting(path);
|
|
133
|
+
const temp = join(dirname(path), `${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
134
|
+
let fresh;
|
|
135
|
+
try {
|
|
136
|
+
fresh = createPrivateFile(temp, text, write);
|
|
137
|
+
try {
|
|
138
|
+
renameSync(temp, path);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
closeSync(fresh.fd);
|
|
141
|
+
unlinkSync(temp);
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (old) closeSync(old.fd);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
if (old) shredFile(path, old);
|
|
149
|
+
return fresh;
|
|
150
|
+
}
|
|
151
|
+
/** A descriptor for the regular file at `path`, opened without following a symlink, or `undefined` if there is none. */
|
|
152
|
+
function openExisting(path) {
|
|
153
|
+
let stat;
|
|
154
|
+
try {
|
|
155
|
+
stat = lstatSync(path);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err.code === "ENOENT") return void 0;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
if (!stat.isFile()) return void 0;
|
|
161
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_NOFOLLOW);
|
|
162
|
+
const { dev, ino } = fstatSync(fd);
|
|
163
|
+
return {
|
|
164
|
+
dev,
|
|
165
|
+
ino,
|
|
166
|
+
fd
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// src/secrets/dotenv-file/dotenv-file.ts
|
|
170
|
+
/** The name of the file, at the repo root, that holds a developer's own tokens. */
|
|
171
|
+
const DOTENV_FILE = ".env";
|
|
172
|
+
/**
|
|
173
|
+
* Read `<repoRoot>/.env` into a record, or `null` when the file is not there (or cannot be read).
|
|
174
|
+
*
|
|
175
|
+
* Only the repo root is looked at, never the directory the command happens to run in, so a `.env` in a checkout you
|
|
176
|
+
* cloned and ran the CLI from is not trusted. The file is optional: nothing asks for it, and a project that keeps its
|
|
177
|
+
* tokens in a `credentials` provider or the keyring does not need one.
|
|
178
|
+
*/
|
|
179
|
+
function readDotenvFile(repoRoot) {
|
|
180
|
+
try {
|
|
181
|
+
return parse(readFileSync(join(repoRoot, DOTENV_FILE)));
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// src/secrets/dotenv-line/dotenv-line.ts
|
|
187
|
+
/** A value dotenv reads back as written without any quoting: no space, quote, `#`, backslash or line break. */
|
|
188
|
+
const PLAIN = /^[A-Za-z0-9_@%+=:,./-]*$/;
|
|
189
|
+
/** The quotes dotenv accepts around a value, in the order they are tried. Single quotes keep the value exactly as written. */
|
|
190
|
+
const QUOTES = [
|
|
191
|
+
"'",
|
|
192
|
+
"\"",
|
|
193
|
+
"`"
|
|
194
|
+
];
|
|
195
|
+
/**
|
|
196
|
+
* `NAME=value` in a form dotenv reads back as the same value, or `null` when there is none.
|
|
197
|
+
*
|
|
198
|
+
* dotenv has no escape for a quote or a backslash: a double-quoted value only turns `\n` and `\r` into line breaks, and
|
|
199
|
+
* leaves every other backslash, an escaped quote included, in the value. So nothing is escaped here. A plain value is
|
|
200
|
+
* written bare; any other is wrapped in the first of `'`, `"` and `` ` `` it does not contain; a value with a line
|
|
201
|
+
* break can only be double-quoted (with `\n` and `\r`) and so must not contain `"` or a backslash. A value that fits
|
|
202
|
+
* none of these is `null`, and the caller says so rather than exporting a different token.
|
|
203
|
+
*/
|
|
204
|
+
function dotenvLine(name, value) {
|
|
205
|
+
if (PLAIN.test(value)) return `${name}=${value}`;
|
|
206
|
+
if (/[\r\n]/.test(value)) {
|
|
207
|
+
if (value.includes("\"") || value.includes("\\")) return null;
|
|
208
|
+
return `${name}="${value.replace(/\r/g, "\\r").replace(/\n/g, "\\n")}"`;
|
|
209
|
+
}
|
|
210
|
+
const quote = QUOTES.find((q) => !value.includes(q));
|
|
211
|
+
return quote ? `${name}=${quote}${value}${quote}` : null;
|
|
212
|
+
}
|
|
213
|
+
// src/secrets/git-ignored/git-ignored.ts
|
|
214
|
+
/**
|
|
215
|
+
* Ask git whether it ignores `path` (see {@link GitIgnoreState}). A caller that must not leave a file where it could be
|
|
216
|
+
* committed accepts only `ignored` and `no-repo`. Reads only; it changes nothing.
|
|
217
|
+
*/
|
|
218
|
+
function isGitIgnored(path) {
|
|
219
|
+
try {
|
|
220
|
+
execFileSync("git", [
|
|
221
|
+
"check-ignore",
|
|
222
|
+
"-q",
|
|
223
|
+
"--",
|
|
224
|
+
path
|
|
225
|
+
], {
|
|
226
|
+
cwd: dirname(path),
|
|
227
|
+
stdio: [
|
|
228
|
+
"ignore",
|
|
229
|
+
"ignore",
|
|
230
|
+
"pipe"
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
return "ignored";
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const { status, code, stderr } = err;
|
|
236
|
+
if (code === "ENOENT") return "no-repo";
|
|
237
|
+
if (status === 1) return "tracked";
|
|
238
|
+
return status === 128 && /not a git repository/i.test(String(stderr)) ? "no-repo" : "unknown";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// src/secrets/keyring/keyring.ts
|
|
242
|
+
function loadBinding() {
|
|
243
|
+
try {
|
|
244
|
+
return createRequire(import.meta.url)("@napi-rs/keyring");
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* A tool's secrets in the OS keyring, under one service name.
|
|
251
|
+
*
|
|
252
|
+
* Keyring access is best-effort. Platforms without a supported credential store (some Linux CI images, sandboxed
|
|
253
|
+
* environments) throw from the underlying library, and `@napi-rs/keyring` is an optional peer that may not be installed
|
|
254
|
+
* at all. Every method catches that and answers `null`, `false` or an empty list rather than propagating, so a caller's
|
|
255
|
+
* chain (a flag, then the environment, then the keyring) falls through to the paths that do work, which is how CI is
|
|
256
|
+
* meant to run.
|
|
257
|
+
*
|
|
258
|
+
* Reads are remembered in memory only, for the life of the process. A cache that outlived the process would mean writing
|
|
259
|
+
* secrets somewhere else, which is what the keyring is there to avoid.
|
|
260
|
+
*/
|
|
261
|
+
function createKeyring(options) {
|
|
262
|
+
const { service } = options;
|
|
263
|
+
const cache = /* @__PURE__ */ new Map();
|
|
264
|
+
let binding = options.binding;
|
|
265
|
+
const loaded = () => {
|
|
266
|
+
if (binding === void 0) binding = loadBinding();
|
|
267
|
+
return binding;
|
|
268
|
+
};
|
|
269
|
+
const entry = (account) => {
|
|
270
|
+
const b = loaded();
|
|
271
|
+
if (!b) throw new Error("no OS keyring available");
|
|
272
|
+
return new b.Entry(service, account);
|
|
273
|
+
};
|
|
274
|
+
return {
|
|
275
|
+
get(account) {
|
|
276
|
+
if (cache.has(account)) return cache.get(account);
|
|
277
|
+
let secret;
|
|
278
|
+
try {
|
|
279
|
+
secret = entry(account).getPassword();
|
|
280
|
+
} catch {
|
|
281
|
+
secret = null;
|
|
282
|
+
}
|
|
283
|
+
cache.set(account, secret);
|
|
284
|
+
return secret;
|
|
285
|
+
},
|
|
286
|
+
set(account, secret) {
|
|
287
|
+
try {
|
|
288
|
+
entry(account).setPassword(secret);
|
|
289
|
+
cache.set(account, secret);
|
|
290
|
+
return true;
|
|
291
|
+
} catch {
|
|
292
|
+
cache.delete(account);
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
delete(account) {
|
|
297
|
+
try {
|
|
298
|
+
const removed = entry(account).deletePassword();
|
|
299
|
+
cache.set(account, null);
|
|
300
|
+
return removed;
|
|
301
|
+
} catch {
|
|
302
|
+
cache.delete(account);
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
list() {
|
|
307
|
+
try {
|
|
308
|
+
const b = loaded();
|
|
309
|
+
return b ? b.findCredentials(service).map((c) => c.account) : [];
|
|
310
|
+
} catch {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
isAvailable() {
|
|
315
|
+
try {
|
|
316
|
+
const b = loaded();
|
|
317
|
+
if (!b) return false;
|
|
318
|
+
b.findCredentials(service);
|
|
319
|
+
return true;
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
clearCache() {
|
|
325
|
+
cache.clear();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
export { DOTENV_FILE, TokenParseError, clearCredentials, copyToClipboard, createKeyring, createPrivateFile, dotenvLine, getCredential, hasCredentials, isGitIgnored, parseTokenArgs, readDotenvFile, setCredentials, shredFile, writeCredentialsFile };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as NodeModule from "node:module";
|
|
2
|
+
import { Argv } from "yargs";
|
|
3
|
+
/** What a run of a built CLI printed and how it ended. */
|
|
4
|
+
interface CliRun {
|
|
5
|
+
/** Everything written to stdout. */
|
|
6
|
+
stdout: string;
|
|
7
|
+
/** Everything written to stderr. */
|
|
8
|
+
stderr: string;
|
|
9
|
+
/** The exit code; -1 if the process was killed by the timeout. */
|
|
10
|
+
code: number;
|
|
11
|
+
}
|
|
12
|
+
/** What one run of the CLI is given. */
|
|
13
|
+
interface RunCliOptions {
|
|
14
|
+
/** Written to the runner's `configFile` in the working directory. A string is written as it is (for a broken file). */
|
|
15
|
+
config?: object | string;
|
|
16
|
+
/** Added to the controlled environment for this run. */
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
/** How long the run may take before it is killed. Default 20 seconds. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
/** What {@link createCliRunner} needs to run a tool's built binary. */
|
|
22
|
+
interface CliRunnerOptions {
|
|
23
|
+
/** The built file to run with Node, e.g. `dist/cli.mjs`. */
|
|
24
|
+
bin: string;
|
|
25
|
+
/** The name of the config file, in the working directory, that a run's `config` is written to (`tool.config.json`). */
|
|
26
|
+
configFile?: string;
|
|
27
|
+
/** Added to the controlled environment of every run, such as a switch that turns telemetry off. */
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
/** The start of the temporary working directory's name. Default `cli-`. */
|
|
30
|
+
prefix?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A function that runs a tool's built binary in a fresh temporary directory with a controlled environment, and returns
|
|
34
|
+
* what it printed and how it exited. The environment is built from nothing: no tokens, no telemetry and no update check
|
|
35
|
+
* leak in from whoever runs the tests, and stdin is closed, so a command that wants a terminal behaves as it does in CI.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createCliRunner(options: CliRunnerOptions): (args: string[], run?: RunCliOptions) => Promise<CliRun>;
|
|
38
|
+
/** What {@link ensureBuilt} needs to decide whether a build is current. */
|
|
39
|
+
interface EnsureBuiltOptions {
|
|
40
|
+
/** The built file the tests run, e.g. `dist/cli.mjs`. */
|
|
41
|
+
bin: string;
|
|
42
|
+
/** The source directory it is built from. A build older than any source file in it is stale. */
|
|
43
|
+
srcDir: string;
|
|
44
|
+
/** The package directory the build runs in. */
|
|
45
|
+
cwd: string;
|
|
46
|
+
/** Folders under `srcDir` whose files do not count as source (test helpers). Default `["testing"]`. */
|
|
47
|
+
ignoreDirs?: string[];
|
|
48
|
+
/** How to build. Default: run the package's own `tsdown`. */
|
|
49
|
+
build?: () => void;
|
|
50
|
+
}
|
|
51
|
+
/** Whether `bin` is missing or older than the newest source file under `srcDir` (tests and `ignoreDirs` not counted). */
|
|
52
|
+
export declare function isStale(bin: string, srcDir: string, ignoreDirs?: readonly string[]): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* For a Vitest global setup: make sure the built file the tests run exists and is newer than the source, so tests of the
|
|
55
|
+
* built binary test what is checked out. It builds only when the file is missing or stale, so a pipeline that built first
|
|
56
|
+
* (turbo does) pays nothing, and a rebuild never wipes `dist` out from under another package's tests.
|
|
57
|
+
*/
|
|
58
|
+
export declare function ensureBuilt(options: EnsureBuiltOptions): void;
|
|
59
|
+
/**
|
|
60
|
+
* A `createRequire` whose `resolve()` only finds modules under the directory it was created for.
|
|
61
|
+
*
|
|
62
|
+
* Node also resolves through `NODE_PATH`, and a test runner started by pnpm puts pnpm's hoisted
|
|
63
|
+
* dependency folder there. A test that creates a temp "repo" and expects a package not to be installed
|
|
64
|
+
* in it would then find the one hoisted for the real workspace, and its result would depend on what
|
|
65
|
+
* the workspace happens to link (a package that becomes a workspace dependency turns up there).
|
|
66
|
+
*
|
|
67
|
+
* Use it in a test that exercises "is this package installed in that repo":
|
|
68
|
+
*
|
|
69
|
+
* vi.mock("node:module", async (importOriginal) => {
|
|
70
|
+
* const actual = await importOriginal<typeof import("node:module")>();
|
|
71
|
+
* const { hermeticCreateRequire } = await import("../../testing/hermetic-require.js");
|
|
72
|
+
* return { ...actual, createRequire: hermeticCreateRequire(actual) };
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
export declare function hermeticCreateRequire(actual: Pick<typeof NodeModule, "createRequire">): typeof NodeModule.createRequire;
|
|
76
|
+
/**
|
|
77
|
+
* Runs one command module through a real yargs parse, so a test covers the command's builder and its handler together.
|
|
78
|
+
* `configure` declares the root options the real CLI has (`--cwd`, `--dry-run`, …) before the command is added. A handler
|
|
79
|
+
* that throws reaches the test as a rejection, not as a message printed by yargs. The handler's collaborators are mocked
|
|
80
|
+
* by the test.
|
|
81
|
+
*/
|
|
82
|
+
export declare function runCommand(module: unknown, args: string[], configure?: (parser: Argv) => Argv): Promise<Record<string, unknown>>;
|
|
83
|
+
export type { CliRun, CliRunnerOptions, EnsureBuiltOptions, RunCliOptions };
|
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, sep } from "node:path";
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import yargs from "yargs";
|
|
8
|
+
// src/testing/cli-runner/cli-runner.ts
|
|
9
|
+
/**
|
|
10
|
+
* A function that runs a tool's built binary in a fresh temporary directory with a controlled environment, and returns
|
|
11
|
+
* what it printed and how it exited. The environment is built from nothing: no tokens, no telemetry and no update check
|
|
12
|
+
* leak in from whoever runs the tests, and stdin is closed, so a command that wants a terminal behaves as it does in CI.
|
|
13
|
+
*/
|
|
14
|
+
function createCliRunner(options) {
|
|
15
|
+
return async (args, run = {}) => {
|
|
16
|
+
const dir = await mkdtemp(join(tmpdir(), options.prefix ?? "cli-"));
|
|
17
|
+
try {
|
|
18
|
+
if (run.config !== void 0) {
|
|
19
|
+
if (!options.configFile) throw new Error("this runner has no configFile, so a run cannot be given a config");
|
|
20
|
+
const body = typeof run.config === "string" ? run.config : JSON.stringify(run.config);
|
|
21
|
+
await writeFile(join(dir, options.configFile), body);
|
|
22
|
+
}
|
|
23
|
+
return await new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(process.execPath, [options.bin, ...args], {
|
|
25
|
+
cwd: dir,
|
|
26
|
+
stdio: [
|
|
27
|
+
"ignore",
|
|
28
|
+
"pipe",
|
|
29
|
+
"pipe"
|
|
30
|
+
],
|
|
31
|
+
env: {
|
|
32
|
+
PATH: process.env["PATH"] ?? "",
|
|
33
|
+
HOME: dir,
|
|
34
|
+
TMPDIR: dir,
|
|
35
|
+
FORCE_COLOR: "0",
|
|
36
|
+
NO_COLOR: "1",
|
|
37
|
+
NO_UPDATE_NOTIFIER: "1",
|
|
38
|
+
...options.env,
|
|
39
|
+
...run.env
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
let stdout = "";
|
|
43
|
+
let stderr = "";
|
|
44
|
+
child.stdout.on("data", (d) => stdout += d);
|
|
45
|
+
child.stderr.on("data", (d) => stderr += d);
|
|
46
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), run.timeoutMs ?? 2e4);
|
|
47
|
+
child.on("error", reject);
|
|
48
|
+
child.on("close", (code) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve({
|
|
51
|
+
stdout,
|
|
52
|
+
stderr,
|
|
53
|
+
code: code ?? -1
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(dir, {
|
|
59
|
+
recursive: true,
|
|
60
|
+
force: true
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// src/testing/ensure-built/ensure-built.ts
|
|
66
|
+
/** The newest modification time of a source file under `dir`, ignoring tests and the folders named in `ignoreDirs`. */
|
|
67
|
+
function newestSource(dir, ignoreDirs) {
|
|
68
|
+
let newest = 0;
|
|
69
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
const path = join(dir, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
if (!ignoreDirs.includes(entry.name)) newest = Math.max(newest, newestSource(path, ignoreDirs));
|
|
73
|
+
} else if (!/\.test\.ts$/.test(entry.name)) newest = Math.max(newest, statSync(path).mtimeMs);
|
|
74
|
+
}
|
|
75
|
+
return newest;
|
|
76
|
+
}
|
|
77
|
+
/** Whether `bin` is missing or older than the newest source file under `srcDir` (tests and `ignoreDirs` not counted). */
|
|
78
|
+
function isStale(bin, srcDir, ignoreDirs = ["testing"]) {
|
|
79
|
+
return !existsSync(bin) || statSync(bin).mtimeMs < newestSource(srcDir, ignoreDirs);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* For a Vitest global setup: make sure the built file the tests run exists and is newer than the source, so tests of the
|
|
83
|
+
* built binary test what is checked out. It builds only when the file is missing or stale, so a pipeline that built first
|
|
84
|
+
* (turbo does) pays nothing, and a rebuild never wipes `dist` out from under another package's tests.
|
|
85
|
+
*/
|
|
86
|
+
function ensureBuilt(options) {
|
|
87
|
+
if (!isStale(options.bin, options.srcDir, options.ignoreDirs)) return;
|
|
88
|
+
if (options.build) return options.build();
|
|
89
|
+
execFileSync(join(options.cwd, "node_modules", ".bin", "tsdown"), {
|
|
90
|
+
cwd: options.cwd,
|
|
91
|
+
stdio: "inherit"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// src/testing/hermetic-require/hermetic-require.ts
|
|
95
|
+
/**
|
|
96
|
+
* A `createRequire` whose `resolve()` only finds modules under the directory it was created for.
|
|
97
|
+
*
|
|
98
|
+
* Node also resolves through `NODE_PATH`, and a test runner started by pnpm puts pnpm's hoisted
|
|
99
|
+
* dependency folder there. A test that creates a temp "repo" and expects a package not to be installed
|
|
100
|
+
* in it would then find the one hoisted for the real workspace, and its result would depend on what
|
|
101
|
+
* the workspace happens to link (a package that becomes a workspace dependency turns up there).
|
|
102
|
+
*
|
|
103
|
+
* Use it in a test that exercises "is this package installed in that repo":
|
|
104
|
+
*
|
|
105
|
+
* vi.mock("node:module", async (importOriginal) => {
|
|
106
|
+
* const actual = await importOriginal<typeof import("node:module")>();
|
|
107
|
+
* const { hermeticCreateRequire } = await import("../../testing/hermetic-require.js");
|
|
108
|
+
* return { ...actual, createRequire: hermeticCreateRequire(actual) };
|
|
109
|
+
* });
|
|
110
|
+
*/
|
|
111
|
+
function hermeticCreateRequire(actual) {
|
|
112
|
+
return ((from) => {
|
|
113
|
+
const real = actual.createRequire(from);
|
|
114
|
+
const file = typeof from === "string" && !from.startsWith("file:") ? from : fileURLToPath(from);
|
|
115
|
+
let root = dirname(file);
|
|
116
|
+
try {
|
|
117
|
+
root = realpathSync(root);
|
|
118
|
+
} catch {}
|
|
119
|
+
const resolve = (id, options) => {
|
|
120
|
+
const found = real.resolve(id, options);
|
|
121
|
+
if (found !== root && !found.startsWith(root + sep)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot find module '${id}' under ${root}`), { code: "MODULE_NOT_FOUND" });
|
|
122
|
+
return found;
|
|
123
|
+
};
|
|
124
|
+
return Object.assign((id) => real(id), real, { resolve: Object.assign(resolve, real.resolve) });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// src/testing/run-command/run-command.ts
|
|
128
|
+
/**
|
|
129
|
+
* Runs one command module through a real yargs parse, so a test covers the command's builder and its handler together.
|
|
130
|
+
* `configure` declares the root options the real CLI has (`--cwd`, `--dry-run`, …) before the command is added. A handler
|
|
131
|
+
* that throws reaches the test as a rejection, not as a message printed by yargs. The handler's collaborators are mocked
|
|
132
|
+
* by the test.
|
|
133
|
+
*/
|
|
134
|
+
async function runCommand(module, args, configure = (parser) => parser) {
|
|
135
|
+
return configure(yargs(args).parserConfiguration({ "populate--": true })).command(module).fail((msg, err) => {
|
|
136
|
+
throw err ?? new Error(msg);
|
|
137
|
+
}).parseAsync();
|
|
138
|
+
}
|
|
139
|
+
export { createCliRunner, ensureBuilt, hermeticCreateRequire, isStale, runCommand };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/secrets/token-args/token-args.ts
|
|
2
|
+
var TokenParseError = class extends Error {
|
|
3
|
+
name = "TokenParseError";
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Converts raw --token CLI values into a typed result.
|
|
7
|
+
*
|
|
8
|
+
* Bare form: --token ghp_xxx → { cliToken: "ghp_xxx" }
|
|
9
|
+
* Keyed form: --token github=ghp_xxx → { cliTokens: { github: "ghp_xxx" } }
|
|
10
|
+
* Mixed: --token github=ghp_xxx --token v_yyy
|
|
11
|
+
* → { cliToken: "v_yyy", cliTokens: { github: "ghp_xxx" } }
|
|
12
|
+
*
|
|
13
|
+
* Values may contain "=" (e.g. base64 strings) — only the first "=" is treated as a separator.
|
|
14
|
+
*/
|
|
15
|
+
function parseTokenArgs(tokens) {
|
|
16
|
+
if (tokens.length === 0) return {};
|
|
17
|
+
const cliTokens = {};
|
|
18
|
+
const bare = [];
|
|
19
|
+
for (const raw of tokens) {
|
|
20
|
+
const eqIdx = raw.indexOf("=");
|
|
21
|
+
if (eqIdx === -1) {
|
|
22
|
+
bare.push(raw);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const vendor = raw.slice(0, eqIdx);
|
|
26
|
+
const value = raw.slice(eqIdx + 1);
|
|
27
|
+
if (vendor.trim() === "") throw new TokenParseError(`invalid --token value "${raw}": vendor name must not be empty`);
|
|
28
|
+
if (/\s/.test(vendor)) throw new TokenParseError(`invalid --token value "${raw}": vendor name must not contain whitespace`);
|
|
29
|
+
if (value === "") throw new TokenParseError(`invalid --token value "${raw}": token value must not be empty`);
|
|
30
|
+
cliTokens[vendor] = value;
|
|
31
|
+
}
|
|
32
|
+
if (bare.length > 1) throw new TokenParseError(`only one bare --token value is allowed; got ${bare.length.toString()} — use vendor=value form for multiple tokens`);
|
|
33
|
+
const result = {};
|
|
34
|
+
if (bare.length === 1) result.cliToken = bare[0];
|
|
35
|
+
if (Object.keys(cliTokens).length > 0) result.cliTokens = cliTokens;
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
export { parseTokenArgs as n, TokenParseError as t };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface ParsedTokenArgs {
|
|
2
|
+
/** Set when a bare (unkeyed) --token value was passed. Used as fallback for all plugins. */
|
|
3
|
+
cliToken?: string;
|
|
4
|
+
/** Set when one or more vendor=value pairs were passed. */
|
|
5
|
+
cliTokens?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export { ParsedTokenArgs as t };
|