@cosyte/script-utils 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@cosyte/script-utils` are documented here, following
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions are managed with Changesets;
5
+ this file is hand-maintained (Changesets' own changelog generation is disabled). The package stays on
6
+ the **`0.0.x`-until-first-alpha** ladder.
7
+
8
+ > Because the generator is disabled, **`[Unreleased]` is promoted to a version heading BY HAND**, in
9
+ > the pull request that adds the changeset. Nothing does it automatically.
10
+
11
+ ## [0.0.1] - 2026-08-05
12
+
13
+ ### Added
14
+
15
+ - First release. `isCliEntrypoint(import.meta.url)` answers "is this module the file Node was
16
+ pointed at", for the `scripts/*.mjs` gates that must not run their CLI body when a test imports
17
+ them for their exports.
18
+
19
+ It replaces the raw string comparison those gates were spelling by hand,
20
+ `` import.meta.url === `file://${resolve(process.argv[1])}` ``, which compares two strings rather
21
+ than two paths and therefore answers `false` for three ordinary invocations: an extension-less
22
+ specifier that Node or `tsx` resolved (`node scripts/gate` running `scripts/gate.js`), a checkout
23
+ under a path containing a space (`import.meta.url` percent-encodes and the concatenation does
24
+ not), and a symlinked invocation (Node resolves the main module to its real path). In each the
25
+ gate exits 0 having checked nothing.
26
+
27
+ Those three forms also COMBINE, and the combination is the one that hides: an extension-less
28
+ `argv[1]` names no file, so it cannot be resolved directly, and any symlinked ancestor then puts
29
+ the two sides in different places. `isCliEntrypoint` resolves the deepest ancestor that does
30
+ exist and keeps the rest verbatim, so `tsx scripts/gate` answers correctly under a symlinked
31
+ checkout.
32
+
33
+ The package carries no runtime dependencies and no build step, because the gates that import it
34
+ run before `pnpm install` on purpose.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cosyte
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 ADDED
@@ -0,0 +1,86 @@
1
+ # @cosyte/script-utils
2
+
3
+ Zero-dependency helpers for the repo-local gate scripts every cosyte repo keeps in `scripts/`.
4
+
5
+ No runtime dependencies and no build step. Both are load-bearing for the gates in `config` itself,
6
+ which run **before `pnpm install`** on purpose, so that a broken or hostile install cannot decide
7
+ whether a release gate runs.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pnpm add -D @cosyte/script-utils
13
+ ```
14
+
15
+ ### If your gate runs before `pnpm install`, do not use the bare specifier
16
+
17
+ Read this before adopting the package, because it is the one thing that does not generalise.
18
+
19
+ A bare `import ... from "@cosyte/script-utils"` resolves through `node_modules`, so it only works
20
+ once the install has happened. `config`'s own two gates run before their install and therefore
21
+ import this file by **relative path** instead, which needs nothing on disk but the checkout.
22
+
23
+ So check when your gate runs, and pick accordingly:
24
+
25
+ | When the gate runs | How to import |
26
+ | -------------------------------- | ------------------------------------------------ |
27
+ | After `pnpm install` (the usual) | `@cosyte/script-utils`, as a `devDependency` |
28
+ | Before `pnpm install` | A relative path to a vendored or checked-in copy |
29
+
30
+ Installing the package does not, on its own, give a pre-install gate the property described above.
31
+
32
+ ## `isCliEntrypoint(import.meta.url)`
33
+
34
+ Is this module the file Node was pointed at, rather than one imported by something else?
35
+
36
+ ```js
37
+ import { isCliEntrypoint } from "@cosyte/script-utils";
38
+
39
+ export function main(argv) {
40
+ /* ... */
41
+ }
42
+
43
+ if (isCliEntrypoint(import.meta.url)) {
44
+ process.exit(main(process.argv.slice(2)));
45
+ }
46
+ ```
47
+
48
+ That guard is what lets a test import the module for its exports without the CLI executing as a side
49
+ effect, while the script still runs normally when invoked.
50
+
51
+ ### Why not compare the strings
52
+
53
+ The obvious spelling is a one-liner, and several cosyte repos wrote it by hand:
54
+
55
+ ```js
56
+ // Do not do this.
57
+ import.meta.url === `file://${resolve(process.argv[1])}`;
58
+ ```
59
+
60
+ It compares two strings rather than two paths, so it answers `false` for three ordinary invocations
61
+ of the script that contains it. All three were measured on Node 22.23.1, not predicted:
62
+
63
+ | Invocation | What happens |
64
+ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `node scripts/gate` | Node resolves the extension and runs `scripts/gate.js`; `argv[1]` keeps `scripts/gate`, which names no file. `tsx scripts/gate` does the same with `.ts`. |
66
+ | A checkout under `/space dir/` | `import.meta.url` percent-encodes to `/space%20dir/`; concatenating `file://` onto a raw path does not. |
67
+ | A symlinked invocation | Node resolves the main module to its real path, so `import.meta.url` is the target while `argv[1]` is the link. `node_modules/.bin` shims hit this. |
68
+
69
+ In every one the guard is `false`, so the gate exits 0 having checked nothing. That is worse than
70
+ the defect the gate was written to catch, because the run conclusion is the only thing anyone reads.
71
+
72
+ `isCliEntrypoint` compares canonical paths, resolves symlinks, and treats an `argv[1]` that names no
73
+ file on disk as a specifier Node had to resolve.
74
+
75
+ ### Which way it errs
76
+
77
+ When the answer is genuinely ambiguous it returns `true` and the CLI runs. A false positive runs a
78
+ gate during an import, which is loud and immediately visible; a false negative skips a gate and
79
+ exits 0, which is silent. Those are not symmetric, so the tie goes to running.
80
+
81
+ It throws a `TypeError` rather than returning `false` when handed something that is not a non-empty
82
+ string, for the same reason.
83
+
84
+ ## License
85
+
86
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Is this module the file Node was pointed at, rather than one imported by something else?
3
+ *
4
+ * Compares the caller's module URL against `process.argv[1]` as PATHS, tolerating the three
5
+ * divergences a raw string comparison gets wrong: an extension-less specifier that Node or a
6
+ * loader resolved, percent-encoding in the module URL, and a symlinked invocation.
7
+ *
8
+ * When the answer is genuinely ambiguous it returns `true`, so an unsure gate runs loudly rather
9
+ * than skipping silently.
10
+ *
11
+ * @param moduleUrl The calling module's `import.meta.url`.
12
+ * @returns `true` when this module is the process entry point.
13
+ * @throws {TypeError} When `moduleUrl` is not a non-empty string.
14
+ */
15
+ export declare function isCliEntrypoint(moduleUrl: string): boolean;
package/index.js ADDED
@@ -0,0 +1,157 @@
1
+ // @cosyte/script-utils
2
+ //
3
+ // SMALL, ZERO-DEPENDENCY HELPERS FOR THE .mjs/.js GATE SCRIPTS EVERY COSYTE REPO KEEPS IN `scripts/`.
4
+ //
5
+ // Zero-dependency is a hard constraint, not a preference. This repo's own two gates
6
+ // (`scripts/changeset-guard.mjs`, `scripts/release-notes.mjs`) run in `ci.yml` and `release.yml`
7
+ // BEFORE `pnpm install`, on purpose, so that a broken or malicious install cannot decide whether a
8
+ // release gate runs. Anything imported by such a gate must therefore resolve with no `node_modules`
9
+ // present. Keep this package free of runtime dependencies and free of a build step.
10
+
11
+ import { statSync, realpathSync } from "node:fs";
12
+ import { basename, dirname, extname, join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ /**
16
+ * Extensions Node or a loader may append to a specifier that was typed without one.
17
+ *
18
+ * The TypeScript entries are here because `tsx`, `ts-node` and `node --experimental-strip-types`
19
+ * all resolve an extension-less specifier to a TypeScript file while leaving `process.argv[1]`
20
+ * exactly as typed.
21
+ */
22
+ const SCRIPT_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]);
23
+
24
+ /**
25
+ * Resolve symlinks, including for a path that does not exist.
26
+ *
27
+ * THE NAIVE FALLBACK HERE IS A SILENT-SKIP BUG, and it takes two conditions that look unrelated.
28
+ * An extension-less `argv[1]` names no file by construction, so `realpathSync` throws on it;
29
+ * returning the input raw then compares an UNRESOLVED path against a resolved one, because the
30
+ * module side always resolves. Add any symlinked ancestor (macOS reaches `tmpdir()` through
31
+ * `/var` to `/private/var`, and a symlinked checkout does the same) and the two disagree, so the
32
+ * gate reports "not the entry point" and exits 0 having checked nothing. That is the combination of
33
+ * `tsx scripts/prebuild` and a symlinked ancestor, which is an ordinary way to run a gate.
34
+ *
35
+ * So resolve the deepest ancestor that DOES exist and keep the rest verbatim.
36
+ *
37
+ * @param {string} path An absolute path.
38
+ * @returns {string} The path with every resolvable ancestor resolved.
39
+ */
40
+ function canonical(path) {
41
+ try {
42
+ return realpathSync(path);
43
+ } catch {
44
+ const parent = dirname(path);
45
+ // At the filesystem root `dirname` is a fixed point. Without this the recursion never ends.
46
+ if (parent === path) return path;
47
+ return join(canonical(parent), basename(path));
48
+ }
49
+ }
50
+
51
+ /**
52
+ * `statSync` that reports a missing path as `null` rather than throwing.
53
+ *
54
+ * @param {string} path An absolute path.
55
+ * @returns {import("node:fs").Stats | null} The stats, or `null`.
56
+ */
57
+ function statOrNull(path) {
58
+ try {
59
+ return statSync(path);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Strip a trailing script extension, so `/a/gate.ts` and `/a/gate` compare equal.
67
+ *
68
+ * @param {string} path An absolute path.
69
+ * @returns {string} `path` without its script extension.
70
+ */
71
+ function withoutScriptExtension(path) {
72
+ const ext = extname(path);
73
+ return SCRIPT_EXTENSIONS.has(ext) ? path.slice(0, -ext.length) : path;
74
+ }
75
+
76
+ /**
77
+ * Is this module the file Node was pointed at, rather than one imported by something else?
78
+ *
79
+ * The question every `scripts/*.mjs` gate asks before running its CLI body, so that a test can
80
+ * import the module for its exports without the CLI executing as a side effect.
81
+ *
82
+ * WHY THIS IS NOT A STRING COMPARISON, all four cases measured on Node 22.23.1 rather than
83
+ * predicted. The obvious spelling,
84
+ *
85
+ * import.meta.url === `file://${resolve(process.argv[1])}`
86
+ *
87
+ * asks whether two strings match, not whether two paths name the same file, and it answers FALSE
88
+ * for three ordinary invocations of the very script that wrote it:
89
+ *
90
+ * 1. EXTENSION-LESS. `node scripts/gate` runs `scripts/gate.js`, and `tsx scripts/gate` runs
91
+ * `scripts/gate.ts`. Node and the loader resolve the extension; `process.argv[1]` keeps the
92
+ * specifier as typed, so it names a file that does not exist. This is the expensive one: it is
93
+ * how a gate goes silent while looking like it ran, and a first cut at this helper that
94
+ * canonicalised each side independently reintroduced it.
95
+ * 2. A PATH CONTAINING A SPACE. `import.meta.url` percent-encodes (`/space%20dir/gate.js`);
96
+ * string-concatenating `file://` onto a raw path does not. Any checkout under a directory with
97
+ * a space in its name disables every gate written this way.
98
+ * 3. A SYMLINKED INVOCATION. Node resolves the main module to its real path, so `import.meta.url`
99
+ * is the target while `process.argv[1]` is the link. Both `node_modules/.bin` shims and a
100
+ * symlinked checkout hit this.
101
+ *
102
+ * In all three the gate exits 0 having checked nothing, which is worse than the defect it guards,
103
+ * because a green run is the only thing anyone reads.
104
+ *
105
+ * WHICH WAY THIS ERRS, deliberately. When the answer is genuinely ambiguous (an extension-less
106
+ * `argv[1]` that resolves to no file on disk, so the stem comparison below is the only evidence
107
+ * available) this returns TRUE and the CLI runs. A false positive runs a gate during an import,
108
+ * which is loud and immediately visible. A false negative skips a gate and exits 0, which is
109
+ * silent. Those are not symmetric, so the tie goes to running.
110
+ *
111
+ * @param {string} moduleUrl The calling module's `import.meta.url`.
112
+ * @returns {boolean} `true` when this module is the process entry point.
113
+ * @throws {TypeError} When `moduleUrl` is not a non-empty string. Passing the wrong thing must be
114
+ * loud: returning `false` would silently disable the caller's CLI.
115
+ * @example
116
+ * if (isCliEntrypoint(import.meta.url)) process.exit(main(process.argv.slice(2)));
117
+ */
118
+ export function isCliEntrypoint(moduleUrl) {
119
+ if (typeof moduleUrl !== "string" || moduleUrl === "") {
120
+ throw new TypeError(
121
+ `isCliEntrypoint(moduleUrl) expects a non-empty string, got ${typeof moduleUrl}. ` +
122
+ `Pass \`import.meta.url\`.`,
123
+ );
124
+ }
125
+
126
+ const entry = process.argv[1];
127
+ // No entry script at all: `node --eval`, the REPL, or an embedder. Nothing was pointed at.
128
+ if (typeof entry !== "string" || entry === "") return false;
129
+
130
+ let modulePath;
131
+ try {
132
+ modulePath = fileURLToPath(moduleUrl);
133
+ } catch {
134
+ // A `data:`, `http:` or `node:` module is never the file Node was pointed at.
135
+ return false;
136
+ }
137
+
138
+ const moduleReal = canonical(modulePath);
139
+ const entryReal = canonical(resolve(entry));
140
+ if (moduleReal === entryReal) return true;
141
+
142
+ // Past here the strings differ, so Node either resolved the specifier or the caller is genuinely
143
+ // not the entry. An `argv[1]` that names a real file is the second case: Node was pointed at some
144
+ // other script and had no resolving to do.
145
+ const entryStat = statOrNull(entryReal);
146
+ if (entryStat !== null && entryStat.isFile()) return false;
147
+
148
+ const entryStem = withoutScriptExtension(entryReal);
149
+ const moduleStem = withoutScriptExtension(moduleReal);
150
+
151
+ // `node scripts/gate` -> `scripts/gate.js`, and `tsx scripts/gate` -> `scripts/gate.ts`.
152
+ if (moduleStem === entryStem) return true;
153
+ // `node ./scripts` and `node ./scripts/` -> `scripts/index.js`.
154
+ if (moduleStem === join(entryStem, "index")) return true;
155
+
156
+ return false;
157
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@cosyte/script-utils",
3
+ "version": "0.0.1",
4
+ "description": "Zero-dependency helpers for the repo-local gate scripts in @cosyte/* repos.",
5
+ "keywords": [
6
+ "cli",
7
+ "entrypoint",
8
+ "import-meta",
9
+ "scripts",
10
+ "cosyte"
11
+ ],
12
+ "homepage": "https://github.com/cosyte/config/tree/main/packages/script-utils#readme",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/cosyte/config.git",
16
+ "directory": "packages/script-utils"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Cosyte <hello@cosyte.com> (https://cosyte.com)",
20
+ "type": "module",
21
+ "main": "index.js",
22
+ "types": "index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./index.d.ts",
26
+ "default": "./index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "index.d.ts",
32
+ "README.md",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22.14"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }