stonyx 0.2.3-beta.95 → 0.2.3-beta.97
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 +2 -2
- package/dist/cli/help.js +2 -2
- package/dist/cli/new.d.ts +47 -1
- package/dist/cli/new.js +135 -6
- package/dist/main.js +7 -3
- package/dist/util/import-config.d.ts +51 -0
- package/dist/util/import-config.js +253 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,12 +22,12 @@ stonyx serve
|
|
|
22
22
|
The `new` command walks you through module selection and generates a ready-to-run project. From there, the CLI handles bootstrapping:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
stonyx serve # Bootstrap + run app.js
|
|
25
|
+
stonyx serve # Bootstrap + run app.ts (or app.js)
|
|
26
26
|
stonyx test # Bootstrap + run tests
|
|
27
27
|
stonyx help # Show all available commands
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Stonyx reads `config/environment.js`, initializes all `@stonyx/*` modules from your `devDependencies`, and runs your application.
|
|
30
|
+
Stonyx reads `config/environment.ts` (preferred) or `config/environment.js`, initializes all `@stonyx/*` modules from your `devDependencies`, and runs your application.
|
|
31
31
|
|
|
32
32
|
## Documentation
|
|
33
33
|
|
package/dist/cli/help.js
CHANGED
|
@@ -23,8 +23,8 @@ export default async function help({ args, builtInCommands, loadModuleCommands }
|
|
|
23
23
|
}
|
|
24
24
|
console.log('\nAliases: s=serve, t=test, n=new, h=help\n');
|
|
25
25
|
console.log('Project conventions:\n');
|
|
26
|
-
console.log(' Entry point: app.js');
|
|
27
|
-
console.log(' Config: config/environment.js');
|
|
26
|
+
console.log(' Entry point: app.ts (or app.js)');
|
|
27
|
+
console.log(' Config: config/environment.ts (or .js)');
|
|
28
28
|
console.log(' DB schema: config/db-schema.js');
|
|
29
29
|
console.log(' Models: models/');
|
|
30
30
|
console.log(' Serializers: serializers/');
|
package/dist/cli/new.d.ts
CHANGED
|
@@ -4,8 +4,49 @@ interface ModuleOption {
|
|
|
4
4
|
dirs?: string[];
|
|
5
5
|
files?: Record<string, () => string>;
|
|
6
6
|
}
|
|
7
|
+
export declare const MODULE_OPTIONS: ModuleOption[];
|
|
7
8
|
export declare function generateDbSchema(): string;
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The version of the `stonyx` package running this generator, read from its own
|
|
11
|
+
* `package.json` at call time.
|
|
12
|
+
*
|
|
13
|
+
* Read rather than hardcoded on purpose: a literal would be correct on the day
|
|
14
|
+
* it was written and silently stale on every day after (abofs/stonyx#113).
|
|
15
|
+
*/
|
|
16
|
+
export declare function readCoreVersion(): string;
|
|
17
|
+
/**
|
|
18
|
+
* The npm dist-tag naming the release line a version belongs to:
|
|
19
|
+
* `0.2.3-beta.96` -> `beta`, `0.2.3-alpha.4` -> `alpha`, `0.2.2` -> `latest`.
|
|
20
|
+
*
|
|
21
|
+
* Every scaffolded `@stonyx/*` module is requested on the same line as the core
|
|
22
|
+
* that scaffolded it, so the generated project cannot mix a prerelease core with
|
|
23
|
+
* modules from the stable line (or the reverse).
|
|
24
|
+
*
|
|
25
|
+
* Throws, rather than guessing, when the prerelease identifier is not usable as a
|
|
26
|
+
* dist-tag. Returning it verbatim was only safe for *alphabetic* identifiers: npm
|
|
27
|
+
* parses a numeric or wildcard identifier as a version **range**, so `0.2.3-0`
|
|
28
|
+
* emitted `"0"` and `0.2.3-x` emitted `"x"`, both of which install with rc=0 and
|
|
29
|
+
* silently resolve the module's `latest` release -- for `@stonyx/orm` that is
|
|
30
|
+
* `0.3.1`, which pins `stonyx@0.2.3-beta.11` and so reproduces abofs/stonyx#113
|
|
31
|
+
* through a different door, with no error. `0.2.3-0` is reachable from this
|
|
32
|
+
* repo's own `publish.yml` `custom-version` dispatch input, which accepts any
|
|
33
|
+
* semver string and also expands the keyword `prerelease` to `x.y.z-0`.
|
|
34
|
+
*
|
|
35
|
+
* Two bounds remain, both loud rather than silent:
|
|
36
|
+
*
|
|
37
|
+
* - An alphabetic identifier is assumed to also be a published dist-tag name.
|
|
38
|
+
* True for `beta` and `alpha`, the only two lines the fleet publishes; a core
|
|
39
|
+
* released as e.g. `0.2.3-rc.1` with no `rc` dist-tag scaffolds an unresolvable
|
|
40
|
+
* module specifier, which fails `pnpm install` with
|
|
41
|
+
* `ERR_PNPM_NO_MATCHING_VERSION`. The tag has to exist on every package in
|
|
42
|
+
* `MODULE_OPTIONS`, not only on the core.
|
|
43
|
+
* - On the stable line this returns `latest`, the correct tag for that line -- but
|
|
44
|
+
* only useful once the *modules* have advanced their own `latest` tags. Today
|
|
45
|
+
* `@stonyx/orm@latest` is `0.3.1` and pins `stonyx@0.2.3-beta.11`, so the module
|
|
46
|
+
* `latest` tags must advance before the core's does (abofs/stonyx#115).
|
|
47
|
+
*/
|
|
48
|
+
export declare function releaseTagFor(version: string): string;
|
|
49
|
+
export declare function generatePackageJson(name: string, selectedModules: ModuleOption[], coreVersion?: string): string;
|
|
9
50
|
export declare function generateAppTs(): string;
|
|
10
51
|
export declare function generateEnvironmentTs(): string;
|
|
11
52
|
export declare function generateTestEnvironmentTs(): string;
|
|
@@ -14,6 +55,11 @@ export declare function generateZzExitTestTs(): string;
|
|
|
14
55
|
export declare function generateEnvironmentExampleTs(): string;
|
|
15
56
|
export declare function generateGitignore(): string;
|
|
16
57
|
export declare function generateTsConfig(): string;
|
|
58
|
+
/**
|
|
59
|
+
* Rejects when `pnpm install` fails, either by exiting non-zero or by failing to
|
|
60
|
+
* spawn. Exported so the rejection path is testable without a network round-trip.
|
|
61
|
+
*/
|
|
62
|
+
export declare function runPnpmInstall(projectDir: string): Promise<void>;
|
|
17
63
|
/**
|
|
18
64
|
* Write every scaffolded file to disk given a resolved project directory,
|
|
19
65
|
* app name, and pre-selected module options. Factored out of `newCommand`
|
package/dist/cli/new.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { confirm, prompt } from '@stonyx/utils/prompt';
|
|
2
2
|
import { createFile, createDirectory, copyFile, fileExists } from '@stonyx/utils/file';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
4
5
|
import path from 'path';
|
|
5
6
|
import { fileURLToPath } from 'url';
|
|
6
|
-
const MODULE_OPTIONS = [
|
|
7
|
+
export const MODULE_OPTIONS = [
|
|
7
8
|
{
|
|
8
9
|
question: 'Will this project need a REST server?',
|
|
9
10
|
package: '@stonyx/rest-server',
|
|
@@ -48,15 +49,128 @@ export default class DBModel extends Model {
|
|
|
48
49
|
}
|
|
49
50
|
`;
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The npm dist-tag that points at a package's stable release line. Only ever
|
|
54
|
+
* used as a *release line* selector for `@stonyx/*` modules (see
|
|
55
|
+
* `releaseTagFor`) -- never as the specifier for the core, which is always
|
|
56
|
+
* pinned exactly.
|
|
57
|
+
*/
|
|
58
|
+
const STABLE_DIST_TAG = 'latest';
|
|
59
|
+
/** Absolute path to this package's own root, from both `src/` and `dist/`. */
|
|
60
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
61
|
+
/** A full semver version: `major.minor.patch`, optional prerelease, optional build. */
|
|
62
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
63
|
+
/**
|
|
64
|
+
* A string npm accepts as a *dist-tag* and never reinterprets as a version range.
|
|
65
|
+
* npm requires a tag to be non-numeric, and `x`/`X` are semver wildcards rather
|
|
66
|
+
* than tag names, so both are rejected in `releaseTagFor`.
|
|
67
|
+
*/
|
|
68
|
+
const DIST_TAG = /^[A-Za-z][0-9A-Za-z-]*$/;
|
|
69
|
+
/**
|
|
70
|
+
* Rejects any version this generator must not emit.
|
|
71
|
+
*
|
|
72
|
+
* `readCoreVersion` used to check only that the field was a non-empty string, so
|
|
73
|
+
* `"garbage"`, `"0.2"` and `"latest"` all flowed through to the manifest verbatim
|
|
74
|
+
* and made `releaseTagFor` fall through to `latest` -- the one specifier
|
|
75
|
+
* abofs/stonyx#113 exists to stop emitting.
|
|
76
|
+
*/
|
|
77
|
+
function assertVersionShape(version, source) {
|
|
78
|
+
if (typeof version !== 'string' || !version) {
|
|
79
|
+
throw new Error(`Could not read the stonyx version from ${source}`);
|
|
80
|
+
}
|
|
81
|
+
if (!SEMVER.test(version)) {
|
|
82
|
+
throw new Error(`The stonyx version from ${source} is not a semver version: "${version}"`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The version of the `stonyx` package running this generator, read from its own
|
|
87
|
+
* `package.json` at call time.
|
|
88
|
+
*
|
|
89
|
+
* Read rather than hardcoded on purpose: a literal would be correct on the day
|
|
90
|
+
* it was written and silently stale on every day after (abofs/stonyx#113).
|
|
91
|
+
*/
|
|
92
|
+
export function readCoreVersion() {
|
|
93
|
+
const manifest = path.join(packageRoot, 'package.json');
|
|
94
|
+
const raw = readFileSync(manifest, 'utf8');
|
|
95
|
+
const version = JSON.parse(raw).version;
|
|
96
|
+
assertVersionShape(version, manifest);
|
|
97
|
+
return version;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The npm dist-tag naming the release line a version belongs to:
|
|
101
|
+
* `0.2.3-beta.96` -> `beta`, `0.2.3-alpha.4` -> `alpha`, `0.2.2` -> `latest`.
|
|
102
|
+
*
|
|
103
|
+
* Every scaffolded `@stonyx/*` module is requested on the same line as the core
|
|
104
|
+
* that scaffolded it, so the generated project cannot mix a prerelease core with
|
|
105
|
+
* modules from the stable line (or the reverse).
|
|
106
|
+
*
|
|
107
|
+
* Throws, rather than guessing, when the prerelease identifier is not usable as a
|
|
108
|
+
* dist-tag. Returning it verbatim was only safe for *alphabetic* identifiers: npm
|
|
109
|
+
* parses a numeric or wildcard identifier as a version **range**, so `0.2.3-0`
|
|
110
|
+
* emitted `"0"` and `0.2.3-x` emitted `"x"`, both of which install with rc=0 and
|
|
111
|
+
* silently resolve the module's `latest` release -- for `@stonyx/orm` that is
|
|
112
|
+
* `0.3.1`, which pins `stonyx@0.2.3-beta.11` and so reproduces abofs/stonyx#113
|
|
113
|
+
* through a different door, with no error. `0.2.3-0` is reachable from this
|
|
114
|
+
* repo's own `publish.yml` `custom-version` dispatch input, which accepts any
|
|
115
|
+
* semver string and also expands the keyword `prerelease` to `x.y.z-0`.
|
|
116
|
+
*
|
|
117
|
+
* Two bounds remain, both loud rather than silent:
|
|
118
|
+
*
|
|
119
|
+
* - An alphabetic identifier is assumed to also be a published dist-tag name.
|
|
120
|
+
* True for `beta` and `alpha`, the only two lines the fleet publishes; a core
|
|
121
|
+
* released as e.g. `0.2.3-rc.1` with no `rc` dist-tag scaffolds an unresolvable
|
|
122
|
+
* module specifier, which fails `pnpm install` with
|
|
123
|
+
* `ERR_PNPM_NO_MATCHING_VERSION`. The tag has to exist on every package in
|
|
124
|
+
* `MODULE_OPTIONS`, not only on the core.
|
|
125
|
+
* - On the stable line this returns `latest`, the correct tag for that line -- but
|
|
126
|
+
* only useful once the *modules* have advanced their own `latest` tags. Today
|
|
127
|
+
* `@stonyx/orm@latest` is `0.3.1` and pins `stonyx@0.2.3-beta.11`, so the module
|
|
128
|
+
* `latest` tags must advance before the core's does (abofs/stonyx#115).
|
|
129
|
+
*/
|
|
130
|
+
export function releaseTagFor(version) {
|
|
131
|
+
// Fail closed, not open. Without this the exported function still answered
|
|
132
|
+
// `latest` for `"garbage"`, `"0.2"`, `""` and `"latest"` itself -- the same
|
|
133
|
+
// fall-through-to-`latest` shape this change exists to remove, one level down
|
|
134
|
+
// from the emission path that `generatePackageJson` already guards.
|
|
135
|
+
assertVersionShape(version, 'the supplied version');
|
|
136
|
+
const prerelease = /^\d+\.\d+\.\d+-(.*)$/.exec(version);
|
|
137
|
+
if (!prerelease)
|
|
138
|
+
return STABLE_DIST_TAG;
|
|
139
|
+
const identifier = prerelease[1].split('.')[0];
|
|
140
|
+
if (!DIST_TAG.test(identifier) || /^[xX]$/.test(identifier)) {
|
|
141
|
+
throw new Error(`Cannot derive a dist-tag from the prerelease identifier "${identifier}" in version "${version}": ` +
|
|
142
|
+
'npm reads it as a version range, not a tag.');
|
|
143
|
+
}
|
|
144
|
+
if (identifier === STABLE_DIST_TAG) {
|
|
145
|
+
throw new Error(`A prerelease core must not request modules at "${STABLE_DIST_TAG}" (version "${version}").`);
|
|
146
|
+
}
|
|
147
|
+
return identifier;
|
|
148
|
+
}
|
|
149
|
+
export function generatePackageJson(name, selectedModules, coreVersion = readCoreVersion()) {
|
|
150
|
+
// Resolved first, before anything is emitted, and unconditionally -- deliberately
|
|
151
|
+
// not inside the module loop below. `releaseTagFor` asserts the version's shape as
|
|
152
|
+
// its first act, so this one call is also what stops an injected non-semver
|
|
153
|
+
// `coreVersion` from reaching the manifest verbatim.
|
|
154
|
+
//
|
|
155
|
+
// A second `assertVersionShape(coreVersion, ...)` stood at the top of this function
|
|
156
|
+
// until abofs/stonyx#118's review: deleting it left the suite green, because this
|
|
157
|
+
// call already rejects the same value on every path. It was removed rather than
|
|
158
|
+
// left as decoration. The invariant that replaces it is positional -- keep this
|
|
159
|
+
// call above the emission and the guard stays load-bearing; push it under an
|
|
160
|
+
// `if (selectedModules.length)` and the core becomes unvalidated again.
|
|
161
|
+
const moduleTag = releaseTagFor(coreVersion);
|
|
162
|
+
// The framework is a runtime dependency of the application, pinned exactly to
|
|
163
|
+
// the core that generated the project.
|
|
164
|
+
const dependencies = { stonyx: coreVersion };
|
|
52
165
|
const devDependencies = {
|
|
53
166
|
qunit: '^2.24.1',
|
|
54
|
-
stonyx: 'latest',
|
|
55
167
|
tsx: '^4.21.0',
|
|
56
168
|
typescript: '^5.8.3'
|
|
57
169
|
};
|
|
170
|
+
// Modules are discovered from devDependencies (see docs/modules.md), and are
|
|
171
|
+
// requested on the core's own release line rather than at `latest`.
|
|
58
172
|
for (const mod of selectedModules) {
|
|
59
|
-
devDependencies[mod.package] =
|
|
173
|
+
devDependencies[mod.package] = moduleTag;
|
|
60
174
|
}
|
|
61
175
|
// Sort dependencies alphabetically
|
|
62
176
|
const sorted = Object.fromEntries(Object.entries(devDependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
@@ -71,6 +185,7 @@ export function generatePackageJson(name, selectedModules) {
|
|
|
71
185
|
start: 'stonyx serve',
|
|
72
186
|
test: "NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
|
|
73
187
|
},
|
|
188
|
+
dependencies,
|
|
74
189
|
devDependencies: sorted
|
|
75
190
|
}, null, 2) + '\n';
|
|
76
191
|
}
|
|
@@ -191,7 +306,11 @@ export function generateTsConfig() {
|
|
|
191
306
|
exclude: ['node_modules', 'test']
|
|
192
307
|
}, null, 2) + '\n';
|
|
193
308
|
}
|
|
194
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Rejects when `pnpm install` fails, either by exiting non-zero or by failing to
|
|
311
|
+
* spawn. Exported so the rejection path is testable without a network round-trip.
|
|
312
|
+
*/
|
|
313
|
+
export function runPnpmInstall(projectDir) {
|
|
195
314
|
return new Promise((resolve, reject) => {
|
|
196
315
|
const child = spawn('pnpm', ['install'], {
|
|
197
316
|
cwd: projectDir,
|
|
@@ -285,7 +404,17 @@ export default async function newCommand({ args }) {
|
|
|
285
404
|
await runPnpmInstall(projectDir);
|
|
286
405
|
}
|
|
287
406
|
catch (error) {
|
|
288
|
-
|
|
407
|
+
// `pnpm`'s own diagnostic already reached the terminal via stdio: 'inherit'.
|
|
408
|
+
// What used to be missing is the exit code: this catch discarded `error` and
|
|
409
|
+
// fell through to the success banner, so `stonyx new` announced success and
|
|
410
|
+
// exited 0 on a project with no node_modules -- loud to a human, silent to any
|
|
411
|
+
// supervisor, CI job or wrapper reading the status.
|
|
412
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
413
|
+
console.error(`\n✗ Project "${appName}" was created, but its dependencies are NOT installed.`);
|
|
414
|
+
console.error(`\n cd ${appName}`);
|
|
415
|
+
console.error(' pnpm install\n');
|
|
416
|
+
process.exitCode = 1;
|
|
417
|
+
return;
|
|
289
418
|
}
|
|
290
419
|
console.log(`\n✓ Project "${appName}" created successfully!`);
|
|
291
420
|
console.log(`\n cd ${appName}`);
|
package/dist/main.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import Chronicle from '@stonyx/logs';
|
|
17
17
|
import loadModules from './modules.js';
|
|
18
18
|
import { mergeObject } from '@stonyx/utils/object';
|
|
19
|
-
import { importConfig } from './util/import-config.js';
|
|
19
|
+
import { importConfig, CONFIG_NOT_FOUND_PREFIX } from './util/import-config.js';
|
|
20
20
|
import { resolveModuleName } from './util/resolve-module-name.js';
|
|
21
21
|
export default class Stonyx {
|
|
22
22
|
static initialized = false;
|
|
@@ -60,8 +60,12 @@ export default class Stonyx {
|
|
|
60
60
|
Object.assign(config, merged);
|
|
61
61
|
}
|
|
62
62
|
catch (err) {
|
|
63
|
-
//
|
|
64
|
-
|
|
63
|
+
// A test override that is genuinely ABSENT is non-fatal. Everything else
|
|
64
|
+
// re-throws — including `CONFIG_NOT_LOADABLE_PREFIX`, which is the whole
|
|
65
|
+
// point of that prefix existing (abofs/stonyx#105, invariant I2). A
|
|
66
|
+
// config the consumer wrote and this loader declined must never reach
|
|
67
|
+
// the caller as "you have no test overrides".
|
|
68
|
+
if (!(err instanceof Error) || !err.message.startsWith(CONFIG_NOT_FOUND_PREFIX))
|
|
65
69
|
throw err;
|
|
66
70
|
}
|
|
67
71
|
}
|
|
@@ -1 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "There is no config here." Callers are allowed to treat this as benign —
|
|
3
|
+
* `src/main.ts` does, for the optional `test/config/environment.*` override.
|
|
4
|
+
*
|
|
5
|
+
* `main.ts` matches on this exact string, so it is exported rather than
|
|
6
|
+
* duplicated: the swallow and the thrower must not be able to drift apart.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CONFIG_NOT_FOUND_PREFIX = "Config not found:";
|
|
9
|
+
/**
|
|
10
|
+
* "There is a config here and I declined to load it." Deliberately NOT prefixed
|
|
11
|
+
* with `CONFIG_NOT_FOUND_PREFIX`, because `main.ts`'s `NODE_ENV=test` catch
|
|
12
|
+
* swallows that prefix and must not swallow this.
|
|
13
|
+
*
|
|
14
|
+
* This is invariant I2 of the Sprint 93 install-and-boot cluster — "no silent
|
|
15
|
+
* decline". Before #105 the two states were literally indistinguishable:
|
|
16
|
+
* measured against `stonyx@0.2.3-beta.95`, a consumer with `config/environment.ts`
|
|
17
|
+
* and the same consumer after `mv config/environment.js config/environment.xyz`
|
|
18
|
+
* both produced the byte-identical `Config not found: .../config/environment.js`.
|
|
19
|
+
*/
|
|
20
|
+
export declare const CONFIG_NOT_LOADABLE_PREFIX = "Config present but not loadable:";
|
|
21
|
+
/**
|
|
22
|
+
* Did this runtime refuse OUR config file, or something our config imported?
|
|
23
|
+
*
|
|
24
|
+
* `FILE_TYPE_REFUSAL_CODES.has(code)` alone cannot tell. A config that loaded
|
|
25
|
+
* and ran perfectly well, but whose own nested `import()` hit a `.ts` under
|
|
26
|
+
* `node_modules`, throws the SAME code — and was reported as "this Node runtime
|
|
27
|
+
* refused to load your config", naming a file that is fine. That misdirection
|
|
28
|
+
* points the consumer at the one file that is not the problem, and the likeliest
|
|
29
|
+
* instance of it is the exact `.ts`-under-node_modules case this loader's own
|
|
30
|
+
* docs tell people to look for.
|
|
31
|
+
*
|
|
32
|
+
* FAIL DIRECTION — this is the property, not an implementation detail.
|
|
33
|
+
* `true` means "re-frame this as a refusal of the config", which is exactly the
|
|
34
|
+
* F-3 misdirection above. So when no path can be extracted this returns
|
|
35
|
+
* `false`, and the caller rethrows Node's own error untouched, `cause` and all.
|
|
36
|
+
* The consumer then reads what actually happened instead of a friendly sentence
|
|
37
|
+
* about the wrong file. This is what makes every future gap in the two patterns
|
|
38
|
+
* harmless: an unparseable message costs the I2 re-framing, not correctness.
|
|
39
|
+
*
|
|
40
|
+
* It previously returned `true` here, on the reasoning that keeping the loud
|
|
41
|
+
* re-frame mattered more than getting the filename right. Measured, that is
|
|
42
|
+
* backwards: the raw error is loud too — `main.ts`'s `NODE_ENV=test` swallow
|
|
43
|
+
* only absorbs `CONFIG_NOT_FOUND_PREFIX`, which a Node refusal never starts
|
|
44
|
+
* with — so failing this way loses nothing and stops naming the wrong file.
|
|
45
|
+
*
|
|
46
|
+
* Exported ONLY so its branches can be reached directly. Driving it through
|
|
47
|
+
* `importConfig` reaches the two path-comparison outcomes and nothing else.
|
|
48
|
+
* A predicate inside the guard that makes failures loud does not get to have
|
|
49
|
+
* branches no test can reach.
|
|
50
|
+
*/
|
|
51
|
+
export declare function refusalIsAboutTheConfig(message: string, configPath: string): boolean;
|
|
1
52
|
export declare function importConfig<T = unknown>(basePath: string): Promise<T>;
|
|
@@ -1,10 +1,256 @@
|
|
|
1
|
-
import { existsSync } from 'fs';
|
|
2
|
-
import { pathToFileURL } from 'url';
|
|
1
|
+
import { existsSync, realpathSync } from 'fs';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
3
|
+
/**
|
|
4
|
+
* Extensions `importConfig` will load, in preference order.
|
|
5
|
+
*
|
|
6
|
+
* `.ts` first. `stonyx@0.2.3-beta.62` shipped exactly this list; some release in
|
|
7
|
+
* `(beta.62, beta.82]` reduced it to a hard-coded `.js`, which is the regression
|
|
8
|
+
* abofs/stonyx#105 reports. Three places in this repo disagreed with that
|
|
9
|
+
* reduction and none were changed with it:
|
|
10
|
+
* - `docs/configuration.md:7,24` documents `.ts` as preferred and describes
|
|
11
|
+
* this exact algorithm verbatim;
|
|
12
|
+
* - `scripts/postinstall.js` writes `config/environment.ts` whenever the
|
|
13
|
+
* consumer has a `tsconfig.json` — the framework wrote the file its own
|
|
14
|
+
* loader then refused to read, which is how every TypeScript consumer (the
|
|
15
|
+
* shape `docs/cli.md:29` calls the default) failed to boot at all;
|
|
16
|
+
* - `src/util/resolve-entry-point.ts` — the sibling resolver, for `app.{ts,js}`
|
|
17
|
+
* — still prefers `.ts` today.
|
|
18
|
+
*/
|
|
19
|
+
const LOADABLE_EXTENSIONS = ['ts', 'js'];
|
|
20
|
+
/**
|
|
21
|
+
* Extensions a consumer plausibly writes a config as, that this loader does not
|
|
22
|
+
* RESOLVE. Their only purpose is to be DETECTED: see `CONFIG_NOT_LOADABLE_PREFIX`.
|
|
23
|
+
*
|
|
24
|
+
* Named for what is actually true of them. `.mjs`, `.cjs` and `.json` are all
|
|
25
|
+
* perfectly readable by Node — `import()` handles every one of them today.
|
|
26
|
+
* Nothing here is unreadable; they are simply not in `LOADABLE_EXTENSIONS`, so
|
|
27
|
+
* this loader never forms a path with them, and a consumer who writes one gets
|
|
28
|
+
* "config present but not loadable" instead of a silent "not found". The old
|
|
29
|
+
* name, UNREADABLE_EXTENSIONS, asserted a property of Node that is false, and
|
|
30
|
+
* invited a future reader to "fix" it by adding them to the resolver.
|
|
31
|
+
*/
|
|
32
|
+
const UNRESOLVED_EXTENSIONS = ['mts', 'cts', 'tsx', 'jsx', 'mjs', 'cjs', 'json'];
|
|
33
|
+
/**
|
|
34
|
+
* "There is no config here." Callers are allowed to treat this as benign —
|
|
35
|
+
* `src/main.ts` does, for the optional `test/config/environment.*` override.
|
|
36
|
+
*
|
|
37
|
+
* `main.ts` matches on this exact string, so it is exported rather than
|
|
38
|
+
* duplicated: the swallow and the thrower must not be able to drift apart.
|
|
39
|
+
*/
|
|
40
|
+
export const CONFIG_NOT_FOUND_PREFIX = 'Config not found:';
|
|
41
|
+
/**
|
|
42
|
+
* "There is a config here and I declined to load it." Deliberately NOT prefixed
|
|
43
|
+
* with `CONFIG_NOT_FOUND_PREFIX`, because `main.ts`'s `NODE_ENV=test` catch
|
|
44
|
+
* swallows that prefix and must not swallow this.
|
|
45
|
+
*
|
|
46
|
+
* This is invariant I2 of the Sprint 93 install-and-boot cluster — "no silent
|
|
47
|
+
* decline". Before #105 the two states were literally indistinguishable:
|
|
48
|
+
* measured against `stonyx@0.2.3-beta.95`, a consumer with `config/environment.ts`
|
|
49
|
+
* and the same consumer after `mv config/environment.js config/environment.xyz`
|
|
50
|
+
* both produced the byte-identical `Config not found: .../config/environment.js`.
|
|
51
|
+
*/
|
|
52
|
+
export const CONFIG_NOT_LOADABLE_PREFIX = 'Config present but not loadable:';
|
|
53
|
+
/**
|
|
54
|
+
* Node error codes that mean "I can see this file and I refuse to load it",
|
|
55
|
+
* as distinct from "your config threw" or "your config imports something
|
|
56
|
+
* missing". Only these are re-framed; everything else propagates untouched so a
|
|
57
|
+
* genuine syntax error in a consumer's config still reads as one.
|
|
58
|
+
*
|
|
59
|
+
* `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` is the load-bearing member:
|
|
60
|
+
* Node refuses to type-strip anything under `node_modules`, so a `@stonyx/*`
|
|
61
|
+
* module that ships `config/environment.ts` crashes every consumer. That rule
|
|
62
|
+
* is unchanged by this fix and is why `docs/conventions/framework-modules.md`
|
|
63
|
+
* still requires modules to ship `.js` — but the failure is now named.
|
|
64
|
+
*/
|
|
65
|
+
const FILE_TYPE_REFUSAL_CODES = new Set([
|
|
66
|
+
'ERR_UNKNOWN_FILE_EXTENSION',
|
|
67
|
+
'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING',
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* The absolute POSIX path a Node refusal names, in the two shapes it names it.
|
|
71
|
+
*
|
|
72
|
+
* Both refusal codes name the file they refused, but differently — measured on
|
|
73
|
+
* node v24.13.0 at this head:
|
|
74
|
+
*
|
|
75
|
+
* ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING
|
|
76
|
+
* Stripping types is currently unsupported for files under node_modules,
|
|
77
|
+
* for "/private/tmp/x/node_modules/dep/thing.ts"
|
|
78
|
+
* ERR_UNKNOWN_FILE_EXTENSION
|
|
79
|
+
* Unknown file extension ".ts" for /private/tmp/x/environment.ts
|
|
80
|
+
*
|
|
81
|
+
* In BOTH shapes the path is the last thing in the message, and both patterns
|
|
82
|
+
* are GREEDY, so each runs to the message tail rather than being bounded by a
|
|
83
|
+
* character class on the path body: the quoted one to the message-final quote,
|
|
84
|
+
* the bare one to the last `.ext` in the message. That is the whole mechanism —
|
|
85
|
+
* measured, an explicit `\s*$` anchor on either one is an equivalent mutant,
|
|
86
|
+
* so it is not there. The previous single pattern instead excluded space, `(`,
|
|
87
|
+
* `)`, `'` and `"` from the body,
|
|
88
|
+
* which meant a config under `~/My Project/` either failed extraction outright
|
|
89
|
+
* or backtracked onto an earlier `.` and yielded a truncated non-path.
|
|
90
|
+
* Measured end-to-end through `dist/` before this change, two consumers one
|
|
91
|
+
* character apart, each with a good `.js` config that nested-imports a `.ts`
|
|
92
|
+
* under `node_modules`:
|
|
93
|
+
*
|
|
94
|
+
* .../plain/config/environment.js -> raw ERR_UNSUPPORTED_...
|
|
95
|
+
* naming node_modules/dep/thing.ts
|
|
96
|
+
* .../My Project/config/environment.js -> "Config present but not loadable:
|
|
97
|
+
* .../My Project/config/environment.js"
|
|
98
|
+
*
|
|
99
|
+
* The second names a `.js` that loaded and ran perfectly. Anchoring on the
|
|
100
|
+
* tail extracts both, and the quoted form tolerates quotes inside the path
|
|
101
|
+
* because it runs to the message-final quote rather than to the first one.
|
|
102
|
+
*
|
|
103
|
+
* `(?:file:\/\/)?` is not defensive padding. Node names the refused file as a
|
|
104
|
+
* `file://` URL whenever the package scope containing it declares a `type`,
|
|
105
|
+
* and as a bare path when it does not -- one function
|
|
106
|
+
* (`stripTypeScriptModuleTypes`) reached from two call sites,
|
|
107
|
+
* `internal/modules/esm/translators:654,663` with the URL and
|
|
108
|
+
* `internal/modules/esm/get_format:148` with `fileURLToPath(url)`. Scope
|
|
109
|
+
* lookup stops at `node_modules`, so for `src/modules.ts:103` the deciding
|
|
110
|
+
* file is the MODULE's `package.json` -- and every `@stonyx/*` module declares
|
|
111
|
+
* `type: 'module'`, as does the app `src/cli/new.ts:81` scaffolds. The URL
|
|
112
|
+
* shape is the default case. See `test/unit/import-config-test.ts` for the
|
|
113
|
+
* measured matrix.
|
|
114
|
+
*
|
|
115
|
+
* Still POSIX-only, and still not exhaustive — a Windows path is not extracted,
|
|
116
|
+
* and neither is a message with trailing text after the path. That is now SAFE
|
|
117
|
+
* rather than merely degraded: see `refusalIsAboutTheConfig`.
|
|
118
|
+
*/
|
|
119
|
+
const QUOTED_REFUSED_PATH = /["']((?:file:\/\/)?\/.+)["']/;
|
|
120
|
+
const BARE_REFUSED_PATH = /(?:^|\s)((?:file:\/\/)?\/.+\.[A-Za-z0-9]+)/;
|
|
121
|
+
/** Resolves symlinks so `/tmp/...` and `/private/tmp/...` compare equal. */
|
|
122
|
+
function realPath(path) {
|
|
123
|
+
try {
|
|
124
|
+
return realpathSync(path);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The path a refusal names, or `null` when no path can be extracted from it.
|
|
132
|
+
*
|
|
133
|
+
* `fileURLToPath` is contained for the same reason `realPath` is: this runs
|
|
134
|
+
* INSIDE `importConfig`'s catch handler, so anything it throws replaces the
|
|
135
|
+
* refusal being handled and takes its `cause` with it — the exact loss
|
|
136
|
+
* invariant I2 exists to prevent. Regex-matchable inputs that throw exist and
|
|
137
|
+
* were measured at this head: `file:///a%2Fb.ts` -> `ERR_INVALID_FILE_URL_PATH`,
|
|
138
|
+
* `file:///%.ts` and `file:///a%zz.ts` -> bare `URIError: URI malformed`.
|
|
139
|
+
*
|
|
140
|
+
* Node's own emissions never produce them — `pathToFileURL` percent-encodes a
|
|
141
|
+
* literal `%` and never emits a bare `%2F` for a POSIX filename — but the
|
|
142
|
+
* message is only USUALLY Node's. `refusalIsAboutTheConfig` is exported public
|
|
143
|
+
* API with no input validation, and a `register()` / `--import` loader hook
|
|
144
|
+
* (this suite itself runs under `tsx`) can throw `ERR_UNKNOWN_FILE_EXTENSION`
|
|
145
|
+
* with any message it likes. Driven end-to-end through `dist/` with such a
|
|
146
|
+
* hook before this containment, the consumer got `ERR_INVALID_FILE_URL_PATH:
|
|
147
|
+
* File URL path must not include encoded / characters` with `cause` undefined,
|
|
148
|
+
* and the refusal it was actually handling was gone.
|
|
149
|
+
*
|
|
150
|
+
* An unparseable URL returns `null`, which the predicate reads as "cannot
|
|
151
|
+
* tell" -> `false` -> the original error propagates untouched. Same fail
|
|
152
|
+
* direction as every other unknown in here.
|
|
153
|
+
*
|
|
154
|
+
* Quoted shape first: it is the more specific of the two. Measured at this
|
|
155
|
+
* head the order is an EQUIVALENT mutant — swapping it leaves the suite at
|
|
156
|
+
* 130/0, because neither measured message shape matches both patterns — so the
|
|
157
|
+
* order is a statement of intent, not a load-bearing branch. Recorded here so
|
|
158
|
+
* the next mutation run does not re-chase it.
|
|
159
|
+
*/
|
|
160
|
+
function refusedPathFrom(message) {
|
|
161
|
+
const refused = QUOTED_REFUSED_PATH.exec(message)?.[1] ?? BARE_REFUSED_PATH.exec(message)?.[1];
|
|
162
|
+
if (!refused)
|
|
163
|
+
return null;
|
|
164
|
+
if (!refused.startsWith('file://'))
|
|
165
|
+
return refused;
|
|
166
|
+
try {
|
|
167
|
+
return fileURLToPath(refused);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Did this runtime refuse OUR config file, or something our config imported?
|
|
175
|
+
*
|
|
176
|
+
* `FILE_TYPE_REFUSAL_CODES.has(code)` alone cannot tell. A config that loaded
|
|
177
|
+
* and ran perfectly well, but whose own nested `import()` hit a `.ts` under
|
|
178
|
+
* `node_modules`, throws the SAME code — and was reported as "this Node runtime
|
|
179
|
+
* refused to load your config", naming a file that is fine. That misdirection
|
|
180
|
+
* points the consumer at the one file that is not the problem, and the likeliest
|
|
181
|
+
* instance of it is the exact `.ts`-under-node_modules case this loader's own
|
|
182
|
+
* docs tell people to look for.
|
|
183
|
+
*
|
|
184
|
+
* FAIL DIRECTION — this is the property, not an implementation detail.
|
|
185
|
+
* `true` means "re-frame this as a refusal of the config", which is exactly the
|
|
186
|
+
* F-3 misdirection above. So when no path can be extracted this returns
|
|
187
|
+
* `false`, and the caller rethrows Node's own error untouched, `cause` and all.
|
|
188
|
+
* The consumer then reads what actually happened instead of a friendly sentence
|
|
189
|
+
* about the wrong file. This is what makes every future gap in the two patterns
|
|
190
|
+
* harmless: an unparseable message costs the I2 re-framing, not correctness.
|
|
191
|
+
*
|
|
192
|
+
* It previously returned `true` here, on the reasoning that keeping the loud
|
|
193
|
+
* re-frame mattered more than getting the filename right. Measured, that is
|
|
194
|
+
* backwards: the raw error is loud too — `main.ts`'s `NODE_ENV=test` swallow
|
|
195
|
+
* only absorbs `CONFIG_NOT_FOUND_PREFIX`, which a Node refusal never starts
|
|
196
|
+
* with — so failing this way loses nothing and stops naming the wrong file.
|
|
197
|
+
*
|
|
198
|
+
* Exported ONLY so its branches can be reached directly. Driving it through
|
|
199
|
+
* `importConfig` reaches the two path-comparison outcomes and nothing else.
|
|
200
|
+
* A predicate inside the guard that makes failures loud does not get to have
|
|
201
|
+
* branches no test can reach.
|
|
202
|
+
*/
|
|
203
|
+
export function refusalIsAboutTheConfig(message, configPath) {
|
|
204
|
+
const refusedPath = refusedPathFrom(message);
|
|
205
|
+
if (refusedPath === null)
|
|
206
|
+
return false;
|
|
207
|
+
return refusedPath === configPath || realPath(refusedPath) === realPath(configPath);
|
|
208
|
+
}
|
|
3
209
|
export async function importConfig(basePath) {
|
|
4
|
-
const
|
|
5
|
-
if (
|
|
6
|
-
|
|
210
|
+
const matches = LOADABLE_EXTENSIONS.filter(ext => existsSync(`${basePath}.${ext}`));
|
|
211
|
+
if (matches.length === 0) {
|
|
212
|
+
const declined = UNRESOLVED_EXTENSIONS.filter(ext => existsSync(`${basePath}.${ext}`));
|
|
213
|
+
if (declined.length > 0) {
|
|
214
|
+
throw new Error(`${CONFIG_NOT_LOADABLE_PREFIX} ${basePath}.${declined.join(` and ${basePath}.`)} ` +
|
|
215
|
+
`exists, but Stonyx loads ${basePath}.ts or ${basePath}.js only. ` +
|
|
216
|
+
`Rename or compile it — this is not a missing config.`);
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`${CONFIG_NOT_FOUND_PREFIX} ${basePath}.{ts,js}`);
|
|
219
|
+
}
|
|
220
|
+
if (matches.length > 1) {
|
|
221
|
+
console.warn(`Warning: both ${basePath}.ts and ${basePath}.js exist. Using .ts — delete the .js to silence this warning (it is likely a stale compiled artifact or postinstall stub).`);
|
|
222
|
+
}
|
|
223
|
+
const path = `${basePath}.${matches[0]}`;
|
|
224
|
+
try {
|
|
225
|
+
const mod = await import(pathToFileURL(path).href);
|
|
226
|
+
return mod.default;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
const code = error?.code;
|
|
230
|
+
if (code && FILE_TYPE_REFUSAL_CODES.has(code) && refusalIsAboutTheConfig(error.message, path)) {
|
|
231
|
+
// Both files exist AND the `.ts` was refused. This is the one state where
|
|
232
|
+
// the both-present warning printed a few lines above is actively wrong
|
|
233
|
+
// advice: it says "delete the .js", and here the `.js` is the only file
|
|
234
|
+
// this runtime can read. Say so explicitly rather than leaving the
|
|
235
|
+
// consumer to reconcile two messages from the same call.
|
|
236
|
+
//
|
|
237
|
+
// It is also a deliberate behaviour change. `stonyx@0.2.3-beta.95`
|
|
238
|
+
// resolved `${basePath}.js` unconditionally, so this exact state LOADED
|
|
239
|
+
// and booted; measured against that loader, same fixture, plain node:
|
|
240
|
+
// it returned the `.js` default export with no warning. Preferring `.ts`
|
|
241
|
+
// is the point of #105, and the population most likely to hold both
|
|
242
|
+
// files is the one that worked around #105 by renaming to `.js` while
|
|
243
|
+
// the postinstall kept writing the `.ts` stub. So this message has to
|
|
244
|
+
// carry the "this used to work" fact, not just the remedy.
|
|
245
|
+
const sibling = matches.length > 1
|
|
246
|
+
? ` ${basePath}.js also exists and this runtime CAN read it, but .ts wins the preference order` +
|
|
247
|
+
` so the .js is never reached. Disregard the "delete the .js" warning above — in THIS state the` +
|
|
248
|
+
` fix is the opposite one: remove or compile ${basePath}.ts.` +
|
|
249
|
+
` (stonyx@0.2.3-beta.95 loaded the .js here; preferring .ts is a deliberate change — abofs/stonyx#105.)`
|
|
250
|
+
: '';
|
|
251
|
+
throw new Error(`${CONFIG_NOT_LOADABLE_PREFIX} ${path} exists, but this Node runtime refused to load it ` +
|
|
252
|
+
`(${code}): ${error.message}.${sibling}`, { cause: error });
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
7
255
|
}
|
|
8
|
-
const mod = await import(pathToFileURL(path).href);
|
|
9
|
-
return mod.default;
|
|
10
256
|
}
|