@forwardimpact/libutil 0.1.87 → 0.1.89
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/bin/fit-download-bundle.js +8 -13
- package/bin/fit-tiktoken.js +7 -12
- package/package.json +3 -1
- package/src/completion-ticket.js +163 -0
- package/src/finder.js +81 -65
- package/src/index.js +5 -2
- package/src/runtime.js +32 -4
- package/src/trusted-origins.js +56 -0
|
@@ -2,25 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import "@forwardimpact/libpreflight/node22";
|
|
4
4
|
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
5
|
import { spawn } from "node:child_process";
|
|
7
6
|
import { createCli } from "@forwardimpact/libcli";
|
|
8
7
|
import { createScriptConfig } from "@forwardimpact/libconfig";
|
|
9
8
|
import { createStorage } from "@forwardimpact/libstorage";
|
|
10
9
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
11
10
|
import { createBundleDownloader, execLine } from "@forwardimpact/libutil";
|
|
12
|
-
|
|
13
|
-
// `bun build --compile` injects FIT_DOWNLOAD_BUNDLE_VERSION via --define,
|
|
14
|
-
// eliminating the readFileSync branch in the compiled binary (which would
|
|
15
|
-
// ENOENT against the bunfs virtual mount). Source execution falls through.
|
|
16
|
-
const VERSION =
|
|
17
|
-
process.env.FIT_DOWNLOAD_BUNDLE_VERSION ||
|
|
18
|
-
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
|
|
19
|
-
.version;
|
|
11
|
+
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
|
|
20
12
|
|
|
21
13
|
const definition = {
|
|
22
14
|
name: "fit-download-bundle",
|
|
23
|
-
version: VERSION,
|
|
24
15
|
description: "Download generated code bundle from remote storage",
|
|
25
16
|
globalOptions: {
|
|
26
17
|
help: { type: "boolean", short: "h", description: "Show this help" },
|
|
@@ -29,8 +20,12 @@ const definition = {
|
|
|
29
20
|
},
|
|
30
21
|
};
|
|
31
22
|
|
|
32
|
-
const
|
|
33
|
-
const
|
|
23
|
+
const runtime = createDefaultRuntime();
|
|
24
|
+
const cli = createCli(definition, {
|
|
25
|
+
runtime,
|
|
26
|
+
packageJsonUrl: new URL("../package.json", import.meta.url),
|
|
27
|
+
});
|
|
28
|
+
const logger = createLogger("generated", runtime);
|
|
34
29
|
|
|
35
30
|
/**
|
|
36
31
|
* Downloads generated code bundle from remote storage.
|
|
@@ -42,7 +37,7 @@ async function main() {
|
|
|
42
37
|
if (!parsed) process.exit(0);
|
|
43
38
|
|
|
44
39
|
await createScriptConfig("download-bundle");
|
|
45
|
-
const downloader = createBundleDownloader(createStorage, logger);
|
|
40
|
+
const downloader = createBundleDownloader(createStorage, logger, runtime);
|
|
46
41
|
await downloader.download();
|
|
47
42
|
|
|
48
43
|
// If additional arguments provided, execute them after download
|
package/bin/fit-tiktoken.js
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "@forwardimpact/libpreflight/node22";
|
|
3
3
|
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
4
|
import { createCli } from "@forwardimpact/libcli";
|
|
5
|
+
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
|
|
6
6
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
7
7
|
import { countTokens } from "@forwardimpact/libutil";
|
|
8
8
|
|
|
9
|
-
// `bun build --compile` injects FIT_TIKTOKEN_VERSION via --define, eliminating
|
|
10
|
-
// the readFileSync branch in the compiled binary (which would ENOENT against
|
|
11
|
-
// the bunfs virtual mount). Source execution falls through to package.json.
|
|
12
|
-
const VERSION =
|
|
13
|
-
process.env.FIT_TIKTOKEN_VERSION ||
|
|
14
|
-
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
|
|
15
|
-
.version;
|
|
16
|
-
|
|
17
9
|
const definition = {
|
|
18
10
|
name: "fit-tiktoken",
|
|
19
|
-
version: VERSION,
|
|
20
11
|
description: "Count tokens in text",
|
|
21
12
|
usage: "fit-tiktoken <text>\n echo 'text' | fit-tiktoken",
|
|
22
13
|
globalOptions: {
|
|
@@ -27,8 +18,12 @@ const definition = {
|
|
|
27
18
|
examples: ['fit-tiktoken "hello world"', "echo 'hello world' | fit-tiktoken"],
|
|
28
19
|
};
|
|
29
20
|
|
|
30
|
-
const
|
|
31
|
-
const
|
|
21
|
+
const runtime = createDefaultRuntime();
|
|
22
|
+
const cli = createCli(definition, {
|
|
23
|
+
runtime,
|
|
24
|
+
packageJsonUrl: new URL("../package.json", import.meta.url),
|
|
25
|
+
});
|
|
26
|
+
const logger = createLogger("tiktoken", runtime);
|
|
32
27
|
|
|
33
28
|
/**
|
|
34
29
|
* Counts tokens in the provided text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libutil",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.89",
|
|
4
4
|
"description": "Cross-cutting utilities: retry, hashing, token counting, and project discovery.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"util",
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"exports": {
|
|
23
23
|
".": "./src/index.js",
|
|
24
24
|
"./runtime": "./src/runtime.js",
|
|
25
|
+
"./trusted-origins": "./src/trusted-origins.js",
|
|
26
|
+
"./completion-ticket": "./src/completion-ticket.js",
|
|
25
27
|
"./git-client": "./src/git-client.js",
|
|
26
28
|
"./gh-client": "./src/gh-client.js",
|
|
27
29
|
"./bin/fit-download-bundle.js": "./bin/fit-download-bundle.js",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { isTrusted } from "./trusted-origins.js";
|
|
4
|
+
|
|
5
|
+
/** Ticket lifetime in milliseconds. 5 minutes covers IdP round-trip + redirect
|
|
6
|
+
* with margin and is well inside browser/proxy URL-lifetime norms. */
|
|
7
|
+
export const TICKET_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canonical-JSON encoding of the ticket payload. Keys are sorted alphabetically
|
|
11
|
+
* (`exp, idp_origin, link_token, surface_user_id`) so the wire bytes do not
|
|
12
|
+
* depend on input-object property iteration order — minting the same claims
|
|
13
|
+
* twice produces byte-identical payloads.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} claims
|
|
16
|
+
* @param {number} claims.exp Absolute ms-since-epoch expiry.
|
|
17
|
+
* @param {string} claims.idpOrigin Normalised IdP origin (`new URL(…).origin`).
|
|
18
|
+
* @param {string} claims.linkToken Opaque link-token claim.
|
|
19
|
+
* @param {string} claims.surfaceUserId Caller surface user id.
|
|
20
|
+
* @returns {string} Canonical JSON.
|
|
21
|
+
*/
|
|
22
|
+
function canonicalJson({ exp, idpOrigin, linkToken, surfaceUserId }) {
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
exp,
|
|
25
|
+
idp_origin: idpOrigin,
|
|
26
|
+
link_token: linkToken,
|
|
27
|
+
surface_user_id: surfaceUserId,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function b64urlEncode(bufOrStr) {
|
|
32
|
+
const buf =
|
|
33
|
+
typeof bufOrStr === "string" ? Buffer.from(bufOrStr, "utf8") : bufOrStr;
|
|
34
|
+
return buf
|
|
35
|
+
.toString("base64")
|
|
36
|
+
.replace(/\+/g, "-")
|
|
37
|
+
.replace(/\//g, "_")
|
|
38
|
+
.replace(/=+$/g, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function b64urlDecodeToBuffer(s) {
|
|
42
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
|
43
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sign(secret, payloadB64) {
|
|
47
|
+
return createHmac("sha256", secret).update(payloadB64).digest();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Mint a completion ticket for the bridge to verify after the IdP round-trip.
|
|
52
|
+
* Wire form: `<base64url(canonicalJson)>.<base64url(hmacSha256(secret, payload))>`.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} args
|
|
55
|
+
* @param {string} args.linkToken Opaque link-token bound to a queued dispatch.
|
|
56
|
+
* @param {string} args.surfaceUserId Caller surface user id.
|
|
57
|
+
* @param {string} args.idpOrigin Normalised IdP origin (`new URL(…).origin`).
|
|
58
|
+
* @param {string} args.secret Shared HMAC secret. Must match the verifier's.
|
|
59
|
+
* @param {number} args.now Absolute ms-since-epoch.
|
|
60
|
+
* @returns {string} Wire-form ticket.
|
|
61
|
+
*/
|
|
62
|
+
export function mintCompletionTicket({
|
|
63
|
+
linkToken,
|
|
64
|
+
surfaceUserId,
|
|
65
|
+
idpOrigin,
|
|
66
|
+
secret,
|
|
67
|
+
now,
|
|
68
|
+
}) {
|
|
69
|
+
const exp = now + TICKET_TTL_MS;
|
|
70
|
+
const payloadJson = canonicalJson({
|
|
71
|
+
exp,
|
|
72
|
+
idpOrigin,
|
|
73
|
+
linkToken,
|
|
74
|
+
surfaceUserId,
|
|
75
|
+
});
|
|
76
|
+
const payloadB64 = b64urlEncode(payloadJson);
|
|
77
|
+
const sig = sign(secret, payloadB64);
|
|
78
|
+
return `${payloadB64}.${b64urlEncode(sig)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Verify a wire-form completion ticket. Returns `{ ok: true, claims }` on
|
|
83
|
+
* success or `{ ok: false, reason }` on failure. All failure reasons are
|
|
84
|
+
* caller-rendered as the same "Unable to verify completion" page —
|
|
85
|
+
* indistinguishability is intentional.
|
|
86
|
+
*
|
|
87
|
+
* Failure reasons: `malformed`, `bad_signature`, `expired`,
|
|
88
|
+
* `link_token_mismatch`, `untrusted_origin`.
|
|
89
|
+
*
|
|
90
|
+
* The `surface_user_id` claim is returned in `claims.surfaceUserId` for the
|
|
91
|
+
* handler to cross-check against the freshly-resolved `pending.surface_user_id`
|
|
92
|
+
* — see `services/bridge` step 8 in the plan. Folding that check into the
|
|
93
|
+
* verifier would require it to take a pending store, which the design
|
|
94
|
+
* explicitly avoids.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} args
|
|
97
|
+
* @param {string} args.ticket Wire-form ticket.
|
|
98
|
+
* @param {{linkToken: string}} args.expected Link-token the handler resolved.
|
|
99
|
+
* @param {Set<string>} args.trustedOrigins Trusted-origin set from libutil.
|
|
100
|
+
* @param {string} args.secret Shared HMAC secret.
|
|
101
|
+
* @param {number} args.now Absolute ms-since-epoch.
|
|
102
|
+
* @returns {{ok: true, claims: {linkToken: string, surfaceUserId: string, idpOrigin: string, exp: number}} | {ok: false, reason: string}}
|
|
103
|
+
*/
|
|
104
|
+
export function verifyCompletionTicket({
|
|
105
|
+
ticket,
|
|
106
|
+
expected,
|
|
107
|
+
trustedOrigins,
|
|
108
|
+
secret,
|
|
109
|
+
now,
|
|
110
|
+
}) {
|
|
111
|
+
if (typeof ticket !== "string" || !ticket.includes(".")) {
|
|
112
|
+
return { ok: false, reason: "malformed" };
|
|
113
|
+
}
|
|
114
|
+
const dot = ticket.indexOf(".");
|
|
115
|
+
if (dot === 0 || dot === ticket.length - 1) {
|
|
116
|
+
return { ok: false, reason: "malformed" };
|
|
117
|
+
}
|
|
118
|
+
const payloadB64 = ticket.slice(0, dot);
|
|
119
|
+
const sigB64 = ticket.slice(dot + 1);
|
|
120
|
+
|
|
121
|
+
const presentedSig = b64urlDecodeToBuffer(sigB64);
|
|
122
|
+
const expectedSig = sign(secret, payloadB64);
|
|
123
|
+
if (
|
|
124
|
+
presentedSig.length !== expectedSig.length ||
|
|
125
|
+
!timingSafeEqual(presentedSig, expectedSig)
|
|
126
|
+
) {
|
|
127
|
+
return { ok: false, reason: "bad_signature" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let claims;
|
|
131
|
+
try {
|
|
132
|
+
claims = JSON.parse(b64urlDecodeToBuffer(payloadB64).toString("utf8"));
|
|
133
|
+
} catch {
|
|
134
|
+
return { ok: false, reason: "malformed" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { exp, idp_origin, link_token, surface_user_id } = claims;
|
|
138
|
+
if (
|
|
139
|
+
typeof exp !== "number" ||
|
|
140
|
+
typeof idp_origin !== "string" ||
|
|
141
|
+
typeof link_token !== "string" ||
|
|
142
|
+
typeof surface_user_id !== "string"
|
|
143
|
+
) {
|
|
144
|
+
return { ok: false, reason: "malformed" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (now >= exp) return { ok: false, reason: "expired" };
|
|
148
|
+
if (link_token !== expected.linkToken) {
|
|
149
|
+
return { ok: false, reason: "link_token_mismatch" };
|
|
150
|
+
}
|
|
151
|
+
if (!isTrusted(idp_origin, trustedOrigins)) {
|
|
152
|
+
return { ok: false, reason: "untrusted_origin" };
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
claims: {
|
|
157
|
+
linkToken: link_token,
|
|
158
|
+
surfaceUserId: surface_user_id,
|
|
159
|
+
idpOrigin: idp_origin,
|
|
160
|
+
exp,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/finder.js
CHANGED
|
@@ -1,81 +1,76 @@
|
|
|
1
|
-
import nodeFsSync from "node:fs";
|
|
2
|
-
import nodeFsPromises from "node:fs/promises";
|
|
3
1
|
import path from "path";
|
|
4
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { LIBCLI_IS_COMPILED } from "@forwardimpact/libcli";
|
|
5
4
|
|
|
6
5
|
const NOOP_LOGGER = { debug() {} };
|
|
7
6
|
|
|
8
|
-
/**
|
|
9
|
-
* Detect the new collaborator-config constructor form. The legacy positional
|
|
10
|
-
* form passes an fs module as the first argument (which carries `readFile`);
|
|
11
|
-
* the new form passes `{ fs, fsSync?, proc, logger? }`.
|
|
12
|
-
* @param {*} arg - The first constructor argument.
|
|
13
|
-
* @returns {boolean}
|
|
14
|
-
*/
|
|
15
|
-
function isRuntimeConfig(arg) {
|
|
16
|
-
return (
|
|
17
|
-
arg != null &&
|
|
18
|
-
typeof arg === "object" &&
|
|
19
|
-
!Array.isArray(arg) &&
|
|
20
|
-
(arg.proc !== undefined ||
|
|
21
|
-
arg.fsSync !== undefined ||
|
|
22
|
-
(arg.fs !== undefined && typeof arg.readFile !== "function"))
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
7
|
/**
|
|
27
8
|
* Finder class for project path resolution and symlink management.
|
|
28
9
|
* Handles filesystem operations for linking generated code to packages.
|
|
29
10
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* The injected `fs` (async) and `fsSync` (sync, for existence checks) flow
|
|
34
|
-
* through to every internal call — the spec-flagged dead-`fs` bug is fixed.
|
|
35
|
-
* - Legacy positional (deprecated, one migration cycle):
|
|
36
|
-
* `new Finder(fs, logger, process)`. Preserved byte-for-byte so existing
|
|
37
|
-
* call sites stay green until their per-unit migration PRs convert them.
|
|
11
|
+
* Constructed with injected collaborators: `new Finder({ fs, fsSync?, proc,
|
|
12
|
+
* logger? })`. The injected `fs` (async) and `fsSync` (sync, for existence
|
|
13
|
+
* checks) flow through to every internal call.
|
|
38
14
|
*/
|
|
39
15
|
export class Finder {
|
|
40
16
|
#fs;
|
|
17
|
+
#fsSync;
|
|
41
18
|
#existsSync;
|
|
42
19
|
#logger;
|
|
43
20
|
#proc;
|
|
21
|
+
#isCompiled;
|
|
44
22
|
|
|
45
23
|
/**
|
|
46
|
-
* @param {object}
|
|
47
|
-
*
|
|
48
|
-
* @param {object} [
|
|
49
|
-
*
|
|
24
|
+
* @param {object} config - Injected collaborators.
|
|
25
|
+
* @param {object} config.fs - Async fs surface (mkdir, lstat, symlink, …).
|
|
26
|
+
* @param {object} [config.fsSync] - Sync fs surface for existence checks;
|
|
27
|
+
* falls back to `fs` when omitted.
|
|
28
|
+
* @param {object} config.proc - Process collaborator (cwd provider).
|
|
29
|
+
* @param {object} [config.logger] - Optional logger; defaults to a no-op.
|
|
30
|
+
* @param {boolean} [config.isCompiled] - Whether the host is a
|
|
31
|
+
* `bun build --compile` binary; defaults to libcli's `LIBCLI_IS_COMPILED`.
|
|
32
|
+
* Injectable so tests can exercise the compiled branch of
|
|
33
|
+
* {@link Finder#findProjectRoot} without a real binary.
|
|
50
34
|
*/
|
|
51
|
-
constructor(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
// Legacy positional form: behavior identical to the pre-1370 Finder —
|
|
70
|
-
// every fs operation routes through the module-level node:fs imports
|
|
71
|
-
// (the historical dead-`fs` behavior callers depend on).
|
|
72
|
-
if (!fsOrConfig) throw new Error("fs is required");
|
|
73
|
-
if (!logger) throw new Error("logger is required");
|
|
74
|
-
if (!proc) throw new Error("process is required");
|
|
75
|
-
this.#fs = nodeFsPromises;
|
|
76
|
-
this.#existsSync = (p) => nodeFsSync.existsSync(p);
|
|
77
|
-
this.#logger = logger;
|
|
35
|
+
constructor(config = {}) {
|
|
36
|
+
// Finder is the one module that legitimately bridges the sync and async
|
|
37
|
+
// fs surfaces (existence checks vs. symlink ops), so it reads both fields
|
|
38
|
+
// by property access rather than a single `{ fs, fsSync }` destructure
|
|
39
|
+
// (which design Decision 7 reserves for consumer modules).
|
|
40
|
+
const fs = config.fs;
|
|
41
|
+
const fsSync = config.fsSync;
|
|
42
|
+
const proc = config.proc;
|
|
43
|
+
if (!fs) throw new Error("fs is required");
|
|
44
|
+
if (!proc) throw new Error("proc is required");
|
|
45
|
+
this.#fs = fs;
|
|
46
|
+
// Retain the raw collaborators so `withLogger` can rebuild an identically
|
|
47
|
+
// bound Finder with a different logger (private fields can't be copied
|
|
48
|
+
// onto a bare clone, and `#existsSync` is derived from `fsSync ?? fs`).
|
|
49
|
+
this.#fsSync = fsSync;
|
|
50
|
+
const existsTarget = fsSync ?? fs;
|
|
51
|
+
this.#existsSync = existsTarget.existsSync.bind(existsTarget);
|
|
78
52
|
this.#proc = proc;
|
|
53
|
+
this.#logger = config.logger ?? NOOP_LOGGER;
|
|
54
|
+
this.#isCompiled = config.isCompiled ?? LIBCLI_IS_COMPILED;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Return a Finder over the same collaborators but with the given logger.
|
|
59
|
+
* The shared `runtime.finder` carries a no-op logger; a site that needs
|
|
60
|
+
* symlink debug logs (e.g. codegen) calls `runtime.finder.withLogger(logger)`
|
|
61
|
+
* instead of constructing its own Finder (Success Criterion 9 keeps `new
|
|
62
|
+
* Finder(...)` inside libutil).
|
|
63
|
+
* @param {object} logger - Logger with a `debug(scope, msg, data)` method.
|
|
64
|
+
* @returns {Finder} A logger-bound view sharing this Finder's fs/proc.
|
|
65
|
+
*/
|
|
66
|
+
withLogger(logger) {
|
|
67
|
+
return new Finder({
|
|
68
|
+
fs: this.#fs,
|
|
69
|
+
fsSync: this.#fsSync,
|
|
70
|
+
proc: this.#proc,
|
|
71
|
+
logger,
|
|
72
|
+
isCompiled: this.#isCompiled,
|
|
73
|
+
});
|
|
79
74
|
}
|
|
80
75
|
|
|
81
76
|
/**
|
|
@@ -119,12 +114,28 @@ export class Finder {
|
|
|
119
114
|
}
|
|
120
115
|
|
|
121
116
|
/**
|
|
122
|
-
* Find the project root
|
|
123
|
-
*
|
|
117
|
+
* Find the project root a tool operates against, transparently handling
|
|
118
|
+
* compiled binaries.
|
|
119
|
+
*
|
|
120
|
+
* In a `bun build --compile` binary the entry module lives in the virtual
|
|
121
|
+
* `/$bunfs` root, so `import.meta.url`/`__dirname`-relative traversal is
|
|
122
|
+
* meaningless — the binary operates on whatever project tree it is launched
|
|
123
|
+
* from, so the working directory *is* the project root. In source/npx
|
|
124
|
+
* execution the working directory may sit anywhere inside the project, so we
|
|
125
|
+
* walk upward from `startPath` for the nearest `package.json`.
|
|
126
|
+
*
|
|
127
|
+
* Folding the compiled check in here keeps it out of every consumer: callers
|
|
128
|
+
* just ask the injected `runtime.finder` for the project root and get the
|
|
129
|
+
* right answer in both worlds.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} [startPath] - Source-mode search origin; defaults to cwd.
|
|
124
132
|
* @returns {string} Project root directory path
|
|
125
133
|
*/
|
|
126
134
|
findProjectRoot(startPath) {
|
|
127
|
-
|
|
135
|
+
if (this.#isCompiled) return this.#proc.cwd();
|
|
136
|
+
|
|
137
|
+
const start = startPath ?? this.#proc.cwd();
|
|
138
|
+
const projectRoot = this.findUpward(start, "package.json", 5);
|
|
128
139
|
if (projectRoot) {
|
|
129
140
|
return path.dirname(projectRoot);
|
|
130
141
|
}
|
|
@@ -194,10 +205,15 @@ export class Finder {
|
|
|
194
205
|
// Ensure the target's parent directory exists before symlinking
|
|
195
206
|
await this.#fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
196
207
|
|
|
197
|
-
//
|
|
198
|
-
|
|
208
|
+
// Link relative to the symlink's own directory, not absolute. A relative
|
|
209
|
+
// target survives being moved or restored at a different path — e.g. the
|
|
210
|
+
// CI workspace cache restoring libraries/*/src/generated on a runner whose
|
|
211
|
+
// checkout root differs from where codegen first ran. An absolute target
|
|
212
|
+
// would dangle, forcing a full codegen re-run on every warm cache.
|
|
213
|
+
const relativeSource = path.relative(path.dirname(targetPath), sourcePath);
|
|
214
|
+
await this.#fs.symlink(relativeSource, targetPath, "dir");
|
|
199
215
|
this.#logger.debug("Finder", "Created symlink", {
|
|
200
|
-
source_path:
|
|
216
|
+
source_path: relativeSource,
|
|
201
217
|
target_path: targetPath,
|
|
202
218
|
});
|
|
203
219
|
}
|
package/src/index.js
CHANGED
|
@@ -101,13 +101,16 @@ export function createTokenizer() {
|
|
|
101
101
|
* Used in containerized deployments to download pre-generated code bundles.
|
|
102
102
|
* @param {Function} createStorage - Storage factory function from libstorage
|
|
103
103
|
* @param {object} logger - Logger instance
|
|
104
|
+
* @param {import("./runtime.js").Runtime} runtime - Injected runtime bag; supplies
|
|
105
|
+
* the `fs`/`fsSync`/`proc` collaborators the Finder reads.
|
|
104
106
|
* @returns {BundleDownloader} Configured BundleDownloader instance
|
|
105
107
|
*/
|
|
106
|
-
export function createBundleDownloader(createStorage, logger) {
|
|
108
|
+
export function createBundleDownloader(createStorage, logger, runtime) {
|
|
107
109
|
if (!createStorage) throw new Error("createStorage is required");
|
|
108
110
|
if (!logger) throw new Error("logger is required");
|
|
111
|
+
if (!runtime) throw new Error("runtime is required");
|
|
109
112
|
|
|
110
|
-
const finder = new Finder(
|
|
113
|
+
const finder = new Finder({ ...runtime, logger });
|
|
111
114
|
const extractor = new TarExtractor(fs, path);
|
|
112
115
|
|
|
113
116
|
return new BundleDownloader(createStorage, finder, logger, extractor);
|
package/src/runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import nodeFsSync, {
|
|
|
4
4
|
createWriteStream as nodeCreateWriteStream,
|
|
5
5
|
} from "node:fs";
|
|
6
6
|
import nodeFs from "node:fs/promises";
|
|
7
|
+
import { Writable } from "node:stream";
|
|
7
8
|
import { Finder } from "./finder.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -29,8 +30,9 @@ import { Finder } from "./finder.js";
|
|
|
29
30
|
* `readFileSync`, `writeFileSync`, `mkdirSync`, `readdirSync`, `statSync`,
|
|
30
31
|
* `openSync`, `readSync`, `closeSync`, `unlinkSync`.
|
|
31
32
|
* @property {Object} proc
|
|
32
|
-
* Process surface: `cwd()`, `env`, `argv`, `stdin`, `stdout
|
|
33
|
-
*
|
|
33
|
+
* Process surface: `cwd()`, `env`, `argv`, `stdin`, `stdout`/`stderr`
|
|
34
|
+
* (pipeline-grade `Writable`s — they support `.write(str)` and also serve as
|
|
35
|
+
* `pipeline()` sinks), `exit(code)`, `kill(pid, signal)` (a negative `pid`
|
|
34
36
|
* signals the process group, e.g. for daemon teardown), `pid` (this
|
|
35
37
|
* process's id — used to exclude self from process-group descendant scans),
|
|
36
38
|
* `platform` (the `process.platform` string — `"darwin"`/`"win32"`/`"linux"`
|
|
@@ -93,8 +95,12 @@ export function createDefaultProc({ source = process, env = source.env } = {}) {
|
|
|
93
95
|
}),
|
|
94
96
|
argv: Object.freeze([...source.argv]),
|
|
95
97
|
stdin: lineIterator(source.stdin),
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
// Pipeline-grade Writables: they support `.write(str)` like the old
|
|
99
|
+
// `{ write }` shim and also serve as `pipeline()` sinks. The wrapper
|
|
100
|
+
// forwards each chunk to the real stream; `.end()` finishes the wrapper
|
|
101
|
+
// without closing `source.stdout`/`stderr`.
|
|
102
|
+
stdout: forwardingWritable(source.stdout),
|
|
103
|
+
stderr: forwardingWritable(source.stderr),
|
|
98
104
|
exit: (code) => source.exit(code),
|
|
99
105
|
kill: (pid, signal) => source.kill(pid, signal),
|
|
100
106
|
pid: source.pid,
|
|
@@ -111,6 +117,28 @@ export function createDefaultProc({ source = process, env = source.env } = {}) {
|
|
|
111
117
|
return proc;
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Wrap an underlying writable (`process.stdout`/`stderr`) in a pipeline-grade
|
|
122
|
+
* `Writable` sink that forwards every chunk to it. Used so `runtime.proc.stdout`
|
|
123
|
+
* can be both `.write(str)`-ed and used as a `pipeline()` destination, without
|
|
124
|
+
* `.end()` on the wrapper closing the real stream.
|
|
125
|
+
* @param {object} target - The underlying writable to forward chunks to.
|
|
126
|
+
* @returns {import("node:stream").Writable}
|
|
127
|
+
*/
|
|
128
|
+
function forwardingWritable(target) {
|
|
129
|
+
return new Writable({
|
|
130
|
+
write(chunk, _encoding, callback) {
|
|
131
|
+
// Forward and complete immediately. The underlying stream's
|
|
132
|
+
// backpressure signal is not propagated, which is fine for the CLI
|
|
133
|
+
// sinks this serves (a one-shot `librc logs()` tail); a future
|
|
134
|
+
// high-volume consumer that needs flow control should respect
|
|
135
|
+
// `target.write()`'s return value here.
|
|
136
|
+
target.write(chunk);
|
|
137
|
+
callback();
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
/**
|
|
115
143
|
* Adapt a readable stream into an `AsyncIterable<string>` of UTF-8 lines.
|
|
116
144
|
* @param {object} stream - A Node readable stream (e.g. `process.stdin`).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a comma-separated list of trusted `https://…` origins into a Set of
|
|
3
|
+
* normalised origin strings (`new URL(s).origin`). Empty entries are dropped.
|
|
4
|
+
* `http://` and malformed entries are skipped at load with a logged warning.
|
|
5
|
+
* No globals read; caller hands in the raw string from its `libconfig`-loaded
|
|
6
|
+
* service config.
|
|
7
|
+
*
|
|
8
|
+
* @param {string|null|undefined} raw Comma-separated string of origins.
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {{warn?: (msg: string, meta?: object) => void}} [options.logger]
|
|
11
|
+
* Optional logger; warnings are emitted but never thrown.
|
|
12
|
+
* @returns {Set<string>} Set of normalised origin strings.
|
|
13
|
+
*/
|
|
14
|
+
export function loadTrustedIdpOrigins(raw, { logger } = {}) {
|
|
15
|
+
const set = new Set();
|
|
16
|
+
const entries = String(raw ?? "")
|
|
17
|
+
.split(",")
|
|
18
|
+
.map((s) => s.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(entry);
|
|
24
|
+
} catch {
|
|
25
|
+
logger?.error?.("trusted-origins", "malformed entry; skipping", {
|
|
26
|
+
entry,
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol !== "https:") {
|
|
31
|
+
logger?.error?.("trusted-origins", "non-TLS entry refused; skipping", {
|
|
32
|
+
entry,
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
set.add(url.origin);
|
|
37
|
+
}
|
|
38
|
+
return set;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Test whether `origin` (any URL string the caller has) belongs to `set`.
|
|
43
|
+
* Compared as `new URL(origin).origin` against the Set's normalised entries.
|
|
44
|
+
* Returns `false` on any URL parse error rather than throwing.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} origin URL or origin string to test.
|
|
47
|
+
* @param {Set<string>} set Set produced by `loadTrustedIdpOrigins`.
|
|
48
|
+
* @returns {boolean} `true` if the URL's origin is in the set.
|
|
49
|
+
*/
|
|
50
|
+
export function isTrusted(origin, set) {
|
|
51
|
+
try {
|
|
52
|
+
return set.has(new URL(origin).origin);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|