@xemahq/repo-build-tooling 0.28.0 → 0.30.0
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/package.json +3 -2
- package/src/effective-input.mjs +25 -31
- package/src/effective-input.test.mjs +64 -19
- package/src/invoked-directly.mjs +38 -0
- package/src/readiness.mjs +6 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -46,12 +46,13 @@
|
|
|
46
46
|
"./service-image": "./src/service-image.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
+
"@xemahq/contracts": "^0.117.1",
|
|
49
50
|
"@xemahq/release-policy": "^16.0.0"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
53
|
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs && node --test src/staged-image-proof.test.mjs && node --test src/effective-input.test.mjs"
|
|
53
54
|
},
|
|
54
55
|
"xemaRelease": {
|
|
55
|
-
"sourceDigest": "sha256:
|
|
56
|
+
"sourceDigest": "sha256:4e12229e82ad289a206a1869e7811343c54502c154d87fd652c435de2263d0d2"
|
|
56
57
|
}
|
|
57
58
|
}
|
package/src/effective-input.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The EFFECTIVE-INPUT key of one image: a content hash of every input that decides its bytes, computed from
|
|
3
3
|
// SOURCES, before any build, so a lane can skip build, stage and push when the key is already in the registry.
|
|
4
4
|
//
|
|
5
|
-
// xema-effective-input --service <pkg> [--service <pkg>]…
|
|
5
|
+
// xema-effective-input --service <pkg> [--service <pkg>]… --platform linux/<amd64|arm64> --base-image <name@sha256:digest>
|
|
6
6
|
// --dockerfile <file> [--script <file>]… [--build-arg NAME=VALUE]… [--out <file>]
|
|
7
7
|
//
|
|
8
8
|
// Run from the workspace root (the repository, or an aggregator workspace root: the staging actions' `context-root`).
|
|
@@ -22,16 +22,19 @@
|
|
|
22
22
|
import { execFileSync } from 'node:child_process';
|
|
23
23
|
import { createHash } from 'node:crypto';
|
|
24
24
|
import { readFileSync, readlinkSync, writeFileSync } from 'node:fs';
|
|
25
|
-
import { join, posix, resolve } from 'node:path';
|
|
25
|
+
import { join, posix, relative, resolve, sep } from 'node:path';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
|
|
28
|
+
import { effectiveInputTag } from '@xemahq/contracts/distribution';
|
|
29
|
+
|
|
30
|
+
import { invokedDirectly } from './invoked-directly.mjs';
|
|
28
31
|
import { rootBuildInputNames } from './staged-image-proof.mjs';
|
|
29
32
|
|
|
30
33
|
export const KEY_SCHEMA = 1;
|
|
31
34
|
/** Build args that are a root-relative path or the commit, never part of the key. */
|
|
32
35
|
export const NON_CONTENT_BUILD_ARGS = Object.freeze(['REPO_DIR', 'SERVICE_PATH', 'BUILD_SHA']);
|
|
33
36
|
const DIGEST_PINNED = /^[^\s@]+@sha256:[0-9a-f]{64}$/;
|
|
34
|
-
const COMPILE_ROOT_FILE = /^(\.npmrc|tsconfig[^/]*\.json)$/;
|
|
37
|
+
const COMPILE_ROOT_FILE = /^(\.npmrc|turbo\.json|tsconfig[^/]*\.json)$/;
|
|
35
38
|
const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
36
39
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
37
40
|
|
|
@@ -277,20 +280,27 @@ export function trackedFiles(root, dir) {
|
|
|
277
280
|
return files;
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
/** `{ fileName: bytes }` of the root files that steer the compile and the build: `.npmrc`, `turbo.json`, `tsconfig*.json`. */
|
|
284
|
+
export function readRootConfig(root) {
|
|
285
|
+
return Object.fromEntries(rootBuildInputNames(root).filter((n) => COMPILE_ROOT_FILE.test(n)).map((n) => [n, readFileSync(join(root, n))]));
|
|
286
|
+
}
|
|
287
|
+
|
|
280
288
|
/** The key and parts of each of `services`, from one package-graph query and one read of the lockfile and tooling. */
|
|
281
289
|
export function effectiveInputKeys({ root, services, platform, baseImage, dockerfile, scripts, buildArgs = {} }) {
|
|
282
290
|
if (!Array.isArray(services) || services.length === 0) throw new Error('at least one service is required');
|
|
283
|
-
|
|
291
|
+
// A script is named by its root-relative path, so two files called `action.yml` in different directories are distinct.
|
|
292
|
+
const scriptName = (x) => relative(root, resolve(root, x)).split(sep).join('/');
|
|
293
|
+
const names = scripts.map(scriptName);
|
|
284
294
|
const duplicate = names.find((n, i) => names.indexOf(n) !== i);
|
|
285
|
-
if (duplicate !== undefined) throw new Error(
|
|
295
|
+
if (duplicate !== undefined) throw new Error(`--script '${duplicate}' is passed twice`);
|
|
286
296
|
const graph = packageGraph(root);
|
|
287
297
|
const toolManifest = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
|
|
288
|
-
const rootConfig =
|
|
298
|
+
const rootConfig = readRootConfig(root);
|
|
289
299
|
const lockText = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
|
|
290
300
|
const install = installInputs({ lockText, packageJson: readFileSync(join(root, 'package.json'), 'utf8'), workspaceYaml: readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') });
|
|
291
301
|
const tooling = {
|
|
292
302
|
dockerfile: readFileSync(dockerfile),
|
|
293
|
-
scripts: Object.fromEntries(scripts.map((s) => [
|
|
303
|
+
scripts: Object.fromEntries(scripts.map((s) => [scriptName(s), readFileSync(resolve(root, s))])),
|
|
294
304
|
buildArgs,
|
|
295
305
|
stageTool: { name: toolManifest.name, version: toolManifest.version },
|
|
296
306
|
};
|
|
@@ -319,27 +329,13 @@ export function effectiveInputKey({ service, ...options }) {
|
|
|
319
329
|
return effectiveInputKeys({ ...options, services: [service] })[service];
|
|
320
330
|
}
|
|
321
331
|
|
|
322
|
-
/** The registries' tag lanes. `Candidate` images are built by the owl candidate run, `Develop` ones by develop CI. */
|
|
323
|
-
export const EffectiveInputLane = Object.freeze({ Candidate: 'candidate', Develop: 'develop' });
|
|
324
|
-
const LANE_PREFIX = Object.freeze({ [EffectiveInputLane.Candidate]: 'cand', [EffectiveInputLane.Develop]: 'ei' });
|
|
325
|
-
|
|
326
|
-
/** The one place a key becomes a registry tag: `cand-<key>` or `ei-<key>`. */
|
|
327
|
-
export function effectiveInputTag(key, lane) {
|
|
328
|
-
if (!/^[0-9a-f]{64}$/.test(key)) throw new Error(`'${key}' is not an effective-input key (64 lowercase hex)`);
|
|
329
|
-
if (!Object.hasOwn(LANE_PREFIX, lane)) throw new Error(`lane must be one of ${Object.values(EffectiveInputLane).join(', ')}, got '${lane}'`);
|
|
330
|
-
return `${LANE_PREFIX[lane]}-${key}`;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
332
|
/**
|
|
334
|
-
* The `--out` map: per service `{ schema, platform, key, parts,
|
|
335
|
-
*
|
|
333
|
+
* The `--out` map: per service `{ schema, platform, key, parts, tag }`. `tag` is built only by `effectiveInputTag`, so a consumer
|
|
334
|
+
* (release-policy's `prepare --effective-inputs`) reads it here and declares no prefix.
|
|
336
335
|
*/
|
|
337
336
|
export function effectiveInputRecord(keys, platform) {
|
|
338
337
|
return Object.fromEntries(
|
|
339
|
-
Object.entries(keys).map(([service, { key, parts }]) => [
|
|
340
|
-
service,
|
|
341
|
-
{ schema: KEY_SCHEMA, platform, key, parts, tags: Object.fromEntries(Object.values(EffectiveInputLane).map((lane) => [lane, effectiveInputTag(key, lane)])) },
|
|
342
|
-
]),
|
|
338
|
+
Object.entries(keys).map(([service, { key, parts }]) => [service, { schema: KEY_SCHEMA, platform, key, parts, tag: effectiveInputTag(key) }]),
|
|
343
339
|
);
|
|
344
340
|
}
|
|
345
341
|
|
|
@@ -347,9 +343,9 @@ export function effectiveInputRecord(keys, platform) {
|
|
|
347
343
|
// `--script` files and the build args. A file the Dockerfile COPYs from OUTSIDE the closure (an entrypoint, infra/docker/*)
|
|
348
344
|
// is keyed only if it is passed as --script: the COPY sources are not derived (ARG-expanded paths such as REPO_DIR make that
|
|
349
345
|
// root-dependent), so the caller must list them.
|
|
350
|
-
const USAGE = 'usage: xema-effective-input --service <pkg> [--service <pkg>]...
|
|
346
|
+
const USAGE = 'usage: xema-effective-input --service <pkg> [--service <pkg>]... --platform linux/<amd64|arm64> --base-image <name@sha256:digest> --dockerfile <file> [--script <file>]... [--build-arg NAME=VALUE]... [--out <file>] (--script: every file the Dockerfile COPYs from outside the closure)';
|
|
351
347
|
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
352
|
-
const SINGLE = ['platform', 'base-image', 'dockerfile', 'out'
|
|
348
|
+
const SINGLE = ['platform', 'base-image', 'dockerfile', 'out'];
|
|
353
349
|
|
|
354
350
|
export function parseArgs(argv) {
|
|
355
351
|
const options = { service: [], script: [], 'build-arg': [] };
|
|
@@ -363,7 +359,6 @@ export function parseArgs(argv) {
|
|
|
363
359
|
}
|
|
364
360
|
for (const required of ['platform', 'base-image', 'dockerfile']) if (!Object.hasOwn(options, required)) throw new Error(USAGE);
|
|
365
361
|
if (options.service.length === 0) throw new Error(USAGE);
|
|
366
|
-
if (options.lane !== undefined && !Object.values(EffectiveInputLane).includes(options.lane)) throw new Error(USAGE);
|
|
367
362
|
const buildArgs = {};
|
|
368
363
|
for (const pair of options['build-arg']) {
|
|
369
364
|
const eq = pair.indexOf('=');
|
|
@@ -374,7 +369,6 @@ export function parseArgs(argv) {
|
|
|
374
369
|
}
|
|
375
370
|
return {
|
|
376
371
|
services: options.service,
|
|
377
|
-
lane: options.lane,
|
|
378
372
|
platform: options.platform,
|
|
379
373
|
baseImage: options['base-image'],
|
|
380
374
|
dockerfile: resolve(options.dockerfile),
|
|
@@ -384,14 +378,14 @@ export function parseArgs(argv) {
|
|
|
384
378
|
};
|
|
385
379
|
}
|
|
386
380
|
|
|
387
|
-
if (
|
|
381
|
+
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
|
388
382
|
try {
|
|
389
|
-
const { out,
|
|
383
|
+
const { out, ...options } = parseArgs(process.argv.slice(2));
|
|
390
384
|
const keys = effectiveInputKeys({ root: process.cwd(), ...options });
|
|
391
385
|
if (out !== undefined) {
|
|
392
386
|
writeFileSync(out, `${JSON.stringify(effectiveInputRecord(keys, options.platform), null, 2)}\n`);
|
|
393
387
|
}
|
|
394
|
-
const shown = Object.fromEntries(Object.entries(keys).map(([service, { key }]) => [service,
|
|
388
|
+
const shown = Object.fromEntries(Object.entries(keys).map(([service, { key }]) => [service, key]));
|
|
395
389
|
process.stdout.write(options.services.length === 1 ? `${shown[options.services[0]]}\n` : `${JSON.stringify(shown)}\n`);
|
|
396
390
|
} catch (error) {
|
|
397
391
|
console.error(`::error::${error.message.split('\n')[0]}`);
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { test } from 'node:test';
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { effectiveInputTag } from '@xemahq/contracts/distribution';
|
|
10
|
+
|
|
11
|
+
import { canonicalJson, closureIn, effectiveInput, effectiveInputKeys, effectiveInputRecord, installInputs, lockfileSlice, parseArgs, readRootConfig, trackedFiles } from './effective-input.mjs';
|
|
9
12
|
|
|
10
13
|
const DIGEST = `ghcr.io/x/node-runtime@sha256:${'a'.repeat(64)}`;
|
|
11
14
|
const lock = (prefix, settings = 'autoInstallPeers: true') => `lockfileVersion: '9.0'
|
|
@@ -145,6 +148,7 @@ test('each input class changes the key', () => {
|
|
|
145
148
|
'base image digest': keyOf(ws, { baseImage: `ghcr.io/x/node-runtime@sha256:${'b'.repeat(64)}` }),
|
|
146
149
|
arch: keyOf(ws, { platform: 'linux/amd64' }),
|
|
147
150
|
'root compile config': keyOf(ws, { rootConfig: { ...base.rootConfig, '.npmrc': 'a=c' } }),
|
|
151
|
+
'root turbo config': keyOf(ws, { rootConfig: { ...base.rootConfig, 'turbo.json': '{"tasks":{}}' } }),
|
|
148
152
|
};
|
|
149
153
|
for (const [name, key] of Object.entries(variants)) assert.notEqual(key, original, `${name} must change the key`);
|
|
150
154
|
assert.equal(new Set(Object.values(variants)).size, Object.keys(variants).length, 'the classes are distinct');
|
|
@@ -199,20 +203,10 @@ test('the command line takes repeated build args and refuses a duplicate', () =>
|
|
|
199
203
|
const argv = ['--service', 's', '--service', 't', '--platform', 'linux/arm64', '--base-image', DIGEST, '--dockerfile', 'D', '--build-arg', 'A=1', '--build-arg', 'B=2=3'];
|
|
200
204
|
assert.deepEqual(parseArgs(argv).buildArgs, { A: '1', B: '2=3' });
|
|
201
205
|
assert.deepEqual(parseArgs(argv).services, ['s', 't']);
|
|
202
|
-
assert.throws(() => parseArgs([...argv, '--lane', '
|
|
206
|
+
assert.throws(() => parseArgs([...argv, '--lane', 'develop']), /usage/);
|
|
203
207
|
assert.throws(() => parseArgs([...argv, '--build-arg', 'A=2']), /given twice/);
|
|
204
208
|
});
|
|
205
209
|
|
|
206
|
-
test('tags name both lanes from one declaration and refuse a bad key or lane', () => {
|
|
207
|
-
const key = 'a'.repeat(64);
|
|
208
|
-
assert.equal(effectiveInputTag(key, EffectiveInputLane.Candidate), `cand-${key}`);
|
|
209
|
-
assert.equal(effectiveInputTag(key, EffectiveInputLane.Develop), `ei-${key}`);
|
|
210
|
-
assert.notEqual(effectiveInputTag(key, EffectiveInputLane.Candidate), effectiveInputTag(key, EffectiveInputLane.Develop));
|
|
211
|
-
assert.throws(() => effectiveInputTag(key, 'prod'), /lane must be one of/);
|
|
212
|
-
assert.throws(() => effectiveInputTag('abc', EffectiveInputLane.Develop), /not an effective-input key/);
|
|
213
|
-
assert.throws(() => effectiveInputTag('cand-' + key, EffectiveInputLane.Develop), /not an effective-input key/);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
210
|
test('the closure is walked transitively from the package graph, including a package with no build task, and refuses an unknown service', () => {
|
|
217
211
|
const graph = new Map([
|
|
218
212
|
['svc', { name: 'svc', dir: 'apps/svc', dependencies: ['mid', 'external'] }],
|
|
@@ -246,14 +240,65 @@ test('a lockfileVersion other than 9.0 is refused by name', () => {
|
|
|
246
240
|
}
|
|
247
241
|
});
|
|
248
242
|
|
|
249
|
-
test('
|
|
250
|
-
assert.throws(() => effectiveInputKeys({ root: '.', services: ['s'], platform: 'linux/arm64', baseImage: DIGEST, dockerfile: 'D', scripts: ['a/x.mjs', '
|
|
243
|
+
test('the same script passed twice is refused', () => {
|
|
244
|
+
assert.throws(() => effectiveInputKeys({ root: '.', services: ['s'], platform: 'linux/arm64', baseImage: DIGEST, dockerfile: 'D', scripts: ['a/x.mjs', './a/x.mjs'] }), /--script 'a\/x.mjs' is passed twice/);
|
|
251
245
|
});
|
|
252
246
|
|
|
253
|
-
test('the
|
|
247
|
+
test('editing the root turbo.json changes the key, through the real root-file read', () => {
|
|
248
|
+
const ws = workspace('');
|
|
249
|
+
try {
|
|
250
|
+
execFileSync('git', ['init', '-q'], { cwd: ws.root });
|
|
251
|
+
writeFileSync(join(ws.root, '.npmrc'), 'a=b');
|
|
252
|
+
writeFileSync(join(ws.root, 'turbo.json'), '{"tasks":{"build":{}}}');
|
|
253
|
+
for (const f of ['.npmrc', 'turbo.json']) execFileSync('git', ['add', f], { cwd: ws.root });
|
|
254
|
+
const keyNow = () => keyOf(ws, { rootConfig: readRootConfig(ws.root) });
|
|
255
|
+
assert.deepEqual(Object.keys(readRootConfig(ws.root)).sort(), ['.npmrc', 'turbo.json']);
|
|
256
|
+
const before = keyNow();
|
|
257
|
+
assert.equal(keyNow(), before);
|
|
258
|
+
writeFileSync(join(ws.root, 'turbo.json'), '{"tasks":{"build":{"outputs":["dist/**"]}}}');
|
|
259
|
+
assert.notEqual(keyNow(), before);
|
|
260
|
+
} finally {
|
|
261
|
+
rmSync(ws.root, { recursive: true });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('the --out map carries the tag per service, from effectiveInputTag', () => {
|
|
254
266
|
const key = 'c'.repeat(64);
|
|
255
267
|
const record = effectiveInputRecord({ 'a-api': { key, parts: { x: 1 } } }, 'linux/amd64');
|
|
256
|
-
assert.
|
|
257
|
-
assert.
|
|
268
|
+
assert.equal(record['a-api'].tag, effectiveInputTag(key));
|
|
269
|
+
assert.equal(record['a-api'].tag, `ei-${key}`);
|
|
258
270
|
assert.equal(record['a-api'].key, key);
|
|
271
|
+
assert.equal('tags' in record['a-api'], false);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// The failure this guards: through a pnpm bin shim `argv[1]` is a symlink, and a bin that compares it to its own real path
|
|
275
|
+
// runs nothing and exits 0. A run with no arguments must print the usage line and exit 1; silence is the failure.
|
|
276
|
+
const USAGE_RUN = (command, args, cwd) => spawnSync(command, args, { cwd, encoding: 'utf8' });
|
|
277
|
+
const assertUsage = (result) => {
|
|
278
|
+
assert.equal(result.status, 1, `exit ${result.status}, stdout '${result.stdout}', stderr '${result.stderr}'`);
|
|
279
|
+
assert.match(result.stderr, /usage: xema-effective-input/);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
test('the bin runs through a symlink to it', () => {
|
|
283
|
+
const dir = mkdtempSync(join(tmpdir(), 'ei-bin-link-'));
|
|
284
|
+
try {
|
|
285
|
+
const link = join(dir, 'xema-effective-input');
|
|
286
|
+
symlinkSync(fileURLToPath(new URL('./effective-input.mjs', import.meta.url)), link);
|
|
287
|
+
assertUsage(USAGE_RUN(process.execPath, [link], dir));
|
|
288
|
+
} finally {
|
|
289
|
+
rmSync(dir, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('the bin runs through `pnpm exec` from an installed copy', () => {
|
|
294
|
+
const dir = mkdtempSync(join(tmpdir(), 'ei-bin-pnpm-'));
|
|
295
|
+
try {
|
|
296
|
+
const packageDir = fileURLToPath(new URL('..', import.meta.url));
|
|
297
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'consumer', private: true, dependencies: { '@xemahq/repo-build-tooling': `link:${packageDir}` } }));
|
|
298
|
+
const install = USAGE_RUN('pnpm', ['install', '--offline', '--ignore-workspace'], dir);
|
|
299
|
+
assert.equal(install.status, 0, install.stderr);
|
|
300
|
+
assertUsage(USAGE_RUN('pnpm', ['exec', 'xema-effective-input'], dir));
|
|
301
|
+
} finally {
|
|
302
|
+
rmSync(dir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
259
304
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
// Side-effect free ON PURPOSE: a CLI imports this to decide whether it was run, and must not execute anything (readiness.mjs, which
|
|
5
|
+
// used to hold it, refuses at import time when the cwd has no package.json).
|
|
6
|
+
/**
|
|
7
|
+
* Was this module RUN, or merely imported (by its own test)?
|
|
8
|
+
*
|
|
9
|
+
* BOTH SIDES ARE REALPATH'D, and that is the whole correctness of it. pnpm's
|
|
10
|
+
* isolated linker puts the package at `node_modules/.pnpm/<pkg>@<ver>/...` and
|
|
11
|
+
* symlinks `node_modules/@scope/<pkg>` to it, so `process.argv[1]` and
|
|
12
|
+
* `import.meta.url` can name the SAME FILE by two different paths. Comparing
|
|
13
|
+
* them unresolved gives an answer that depends on HOW THE PACKAGE WAS LINKED —
|
|
14
|
+
* true through pnpm's bin shim, which invokes the realpath, and false when the
|
|
15
|
+
* symlinked path is invoked. A predicate whose answer depends on the linker is
|
|
16
|
+
* not a predicate.
|
|
17
|
+
*
|
|
18
|
+
* The failure mode is the one this whole tool exists to prevent: a false answer
|
|
19
|
+
* takes the import branch, `run()` is never called, and the process exits 0 —
|
|
20
|
+
* a chain reporting success without running. Caught by a peer, not by me, and
|
|
21
|
+
* not by any test, which is why `invokedDirectly` is exported and asserted
|
|
22
|
+
* below against a REAL symlink.
|
|
23
|
+
*
|
|
24
|
+
* `fileURLToPath` rather than `new URL(...).pathname`: the latter is not the
|
|
25
|
+
* documented conversion and is wrong independently of the symlink — it breaks on
|
|
26
|
+
* a Windows drive letter and on any percent-encoded character, and a single
|
|
27
|
+
* SPACE in a checkout path is enough to produce one.
|
|
28
|
+
*/
|
|
29
|
+
export function invokedDirectly(argv1, metaUrl) {
|
|
30
|
+
if (argv1 === undefined) return false;
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(metaUrl));
|
|
33
|
+
} catch {
|
|
34
|
+
// An unresolvable path cannot be this module; never guess YES, because a
|
|
35
|
+
// wrong YES runs the chain from a context that did not ask for it.
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/readiness.mjs
CHANGED
|
@@ -63,11 +63,12 @@
|
|
|
63
63
|
*/
|
|
64
64
|
import { execFileSync } from 'node:child_process';
|
|
65
65
|
import { createRequire } from 'node:module';
|
|
66
|
-
import { existsSync, readdirSync, readFileSync,
|
|
66
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
67
67
|
import path from 'node:path';
|
|
68
|
-
import { fileURLToPath } from 'node:url';
|
|
69
68
|
import process from 'node:process';
|
|
70
69
|
|
|
70
|
+
import { invokedDirectly } from './invoked-directly.mjs';
|
|
71
|
+
|
|
71
72
|
// THE ROOT IS THE CONSUMER'S REPOSITORY, NEVER THIS PACKAGE'S LOCATION.
|
|
72
73
|
// This ships as a bin from `@xemahq/repo-build-tooling`, so `import.meta.url`
|
|
73
74
|
// resolves inside `node_modules` — deriving the root from it would grade the
|
|
@@ -744,39 +745,9 @@ function run() {
|
|
|
744
745
|
return 0;
|
|
745
746
|
}
|
|
746
747
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
* BOTH SIDES ARE REALPATH'D, and that is the whole correctness of it. pnpm's
|
|
751
|
-
* isolated linker puts the package at `node_modules/.pnpm/<pkg>@<ver>/...` and
|
|
752
|
-
* symlinks `node_modules/@scope/<pkg>` to it, so `process.argv[1]` and
|
|
753
|
-
* `import.meta.url` can name the SAME FILE by two different paths. Comparing
|
|
754
|
-
* them unresolved gives an answer that depends on HOW THE PACKAGE WAS LINKED —
|
|
755
|
-
* true through pnpm's bin shim, which invokes the realpath, and false when the
|
|
756
|
-
* symlinked path is invoked. A predicate whose answer depends on the linker is
|
|
757
|
-
* not a predicate.
|
|
758
|
-
*
|
|
759
|
-
* The failure mode is the one this whole tool exists to prevent: a false answer
|
|
760
|
-
* takes the import branch, `run()` is never called, and the process exits 0 —
|
|
761
|
-
* a chain reporting success without running. Caught by a peer, not by me, and
|
|
762
|
-
* not by any test, which is why `invokedDirectly` is exported and asserted
|
|
763
|
-
* below against a REAL symlink.
|
|
764
|
-
*
|
|
765
|
-
* `fileURLToPath` rather than `new URL(...).pathname`: the latter is not the
|
|
766
|
-
* documented conversion and is wrong independently of the symlink — it breaks on
|
|
767
|
-
* a Windows drive letter and on any percent-encoded character, and a single
|
|
768
|
-
* SPACE in a checkout path is enough to produce one.
|
|
769
|
-
*/
|
|
770
|
-
export function invokedDirectly(argv1, metaUrl) {
|
|
771
|
-
if (argv1 === undefined) return false;
|
|
772
|
-
try {
|
|
773
|
-
return realpathSync(argv1) === realpathSync(fileURLToPath(metaUrl));
|
|
774
|
-
} catch {
|
|
775
|
-
// An unresolvable path cannot be this module; never guess YES, because a
|
|
776
|
-
// wrong YES runs the chain from a context that did not ask for it.
|
|
777
|
-
return false;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
748
|
+
// `invokedDirectly` lives in its own side-effect-free module (this one does work at import time); re-exported so every importer of it
|
|
749
|
+
// from here, and its test, keep working.
|
|
750
|
+
export { invokedDirectly };
|
|
780
751
|
|
|
781
752
|
const INVOKED_DIRECTLY = invokedDirectly(process.argv[1], import.meta.url);
|
|
782
753
|
|