hammerkit 1.13.1 → 1.14.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/build.schema.json
CHANGED
|
@@ -501,13 +501,44 @@
|
|
|
501
501
|
"references": {
|
|
502
502
|
"type": "object",
|
|
503
503
|
"additionalProperties": {
|
|
504
|
-
"
|
|
504
|
+
"anyOf": [
|
|
505
|
+
{
|
|
506
|
+
"type": "string"
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
"type": "object",
|
|
510
|
+
"properties": {
|
|
511
|
+
"git": {
|
|
512
|
+
"type": "string",
|
|
513
|
+
"minLength": 1,
|
|
514
|
+
"description": "Git repository URL or path"
|
|
515
|
+
},
|
|
516
|
+
"ref": {
|
|
517
|
+
"type": "string",
|
|
518
|
+
"minLength": 1,
|
|
519
|
+
"description": "Branch, tag or commit SHA, the repository HEAD when omitted"
|
|
520
|
+
},
|
|
521
|
+
"path": {
|
|
522
|
+
"type": "string",
|
|
523
|
+
"description": "Build file or directory within the repository"
|
|
524
|
+
},
|
|
525
|
+
"with": {
|
|
526
|
+
"$ref": "#/definitions/hammerkit/properties/envs",
|
|
527
|
+
"description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
"required": [
|
|
531
|
+
"git"
|
|
532
|
+
],
|
|
533
|
+
"additionalProperties": false
|
|
534
|
+
}
|
|
535
|
+
]
|
|
505
536
|
}
|
|
506
537
|
},
|
|
507
538
|
"includes": {
|
|
508
539
|
"type": "object",
|
|
509
540
|
"additionalProperties": {
|
|
510
|
-
"
|
|
541
|
+
"$ref": "#/definitions/hammerkit/properties/references/additionalProperties"
|
|
511
542
|
}
|
|
512
543
|
},
|
|
513
544
|
"environments": {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isWithin = exports.resolveGitSource = exports.describeGitSource = exports.getIncludesDirectory = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const promises_1 = require("fs/promises");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const util_1 = require("util");
|
|
9
|
+
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
10
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
11
|
+
// Anything but plain network and local transports is refused: `ext::` runs a
|
|
12
|
+
// command, and a remote build file can name any repository.
|
|
13
|
+
const ALLOWED_PROTOCOLS = 'file:git:http:https:ssh';
|
|
14
|
+
function getIncludesDirectory() {
|
|
15
|
+
return (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'includes');
|
|
16
|
+
}
|
|
17
|
+
exports.getIncludesDirectory = getIncludesDirectory;
|
|
18
|
+
function getSourceKey(source) {
|
|
19
|
+
return (0, crypto_1.createHash)('sha256')
|
|
20
|
+
.update(`${source.git}\n${source.ref ?? ''}`)
|
|
21
|
+
.digest('hex')
|
|
22
|
+
.slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
function describeGitSource(source) {
|
|
25
|
+
return `${source.git} at ${source.ref ?? 'HEAD'}`;
|
|
26
|
+
}
|
|
27
|
+
exports.describeGitSource = describeGitSource;
|
|
28
|
+
// The cache is the pin (ADR-0004): a source that was resolved once is served from
|
|
29
|
+
// disk, offline too, until it is refreshed or purged. Only a source that was
|
|
30
|
+
// never resolved needs the network.
|
|
31
|
+
async function resolveGitSource(source, environment) {
|
|
32
|
+
const directory = (0, path_1.join)(getIncludesDirectory(), getSourceKey(source));
|
|
33
|
+
const cached = await readCached(directory);
|
|
34
|
+
if (cached) {
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
await (0, promises_1.mkdir)(getIncludesDirectory(), { recursive: true });
|
|
38
|
+
const staging = `${directory}.${(0, crypto_1.randomUUID)()}`;
|
|
39
|
+
try {
|
|
40
|
+
await (0, promises_1.mkdir)(staging, { recursive: true });
|
|
41
|
+
const commit = await fetchInto((0, path_1.join)(staging, 'repo'), source, environment);
|
|
42
|
+
const stored = { git: source.git, ref: source.ref ?? null, commit };
|
|
43
|
+
await (0, promises_1.writeFile)((0, path_1.join)(staging, 'source.json'), JSON.stringify(stored, null, 2));
|
|
44
|
+
try {
|
|
45
|
+
await (0, promises_1.rename)(staging, directory);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
// a parallel run resolved the same source first; both hold the same ref
|
|
49
|
+
const winner = await readCached(directory);
|
|
50
|
+
if (!winner) {
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
const resolved = await readCached(directory);
|
|
59
|
+
if (!resolved) {
|
|
60
|
+
throw new Error(`unable to cache ${describeGitSource(source)}`);
|
|
61
|
+
}
|
|
62
|
+
return resolved;
|
|
63
|
+
}
|
|
64
|
+
exports.resolveGitSource = resolveGitSource;
|
|
65
|
+
async function readCached(directory) {
|
|
66
|
+
try {
|
|
67
|
+
const stored = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(directory, 'source.json'), 'utf8'));
|
|
68
|
+
return { git: stored.git, ref: stored.ref, commit: stored.commit, root: (0, path_1.join)(directory, 'repo') };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function fetchInto(repo, source, environment) {
|
|
75
|
+
await git(['init', '--quiet', repo], source, environment);
|
|
76
|
+
await git(['-C', repo, 'fetch', '--quiet', '--depth', '1', '--', source.git, source.ref ?? 'HEAD'], source, environment);
|
|
77
|
+
await git(['-C', repo, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', 'FETCH_HEAD'], source, environment);
|
|
78
|
+
const { stdout } = await git(['-C', repo, 'rev-parse', 'HEAD'], source, environment);
|
|
79
|
+
return stdout.trim();
|
|
80
|
+
}
|
|
81
|
+
async function git(args, source, environment) {
|
|
82
|
+
const env = {};
|
|
83
|
+
for (const [key, value] of Object.entries(environment.processEnvs)) {
|
|
84
|
+
if (value !== undefined) {
|
|
85
|
+
env[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// credentials come from the user's git configuration and helpers; there is no
|
|
89
|
+
// interactive prompt a build could hang on
|
|
90
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
91
|
+
env.GIT_ALLOW_PROTOCOL = ALLOWED_PROTOCOLS;
|
|
92
|
+
try {
|
|
93
|
+
return await execFileAsync('git', args, { env, signal: environment.abortCtrl.signal });
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
const error = e;
|
|
97
|
+
if (error.code === 'ENOENT') {
|
|
98
|
+
throw new Error(`unable to resolve ${describeGitSource(source)}: git is not installed`);
|
|
99
|
+
}
|
|
100
|
+
const reason = (error.stderr ?? '')
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map((line) => line.trim())
|
|
103
|
+
.filter((line) => line.length > 0)
|
|
104
|
+
.pop() ?? error.message;
|
|
105
|
+
throw new Error(`unable to resolve ${describeGitSource(source)}: ${reason}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function isWithin(root, path) {
|
|
109
|
+
const rel = (0, path_1.relative)(root, path);
|
|
110
|
+
return !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel);
|
|
111
|
+
}
|
|
112
|
+
exports.isWithin = isWithin;
|
|
113
|
+
//# sourceMappingURL=resolve-git-source.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isGitSource = exports.buildFileIncludeSchema = exports.buildFileGitSourceSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const envs_schema_1 = require("./envs-schema");
|
|
6
|
+
// A repo URL or ref starting with `-` would be read by git as an option
|
|
7
|
+
// (`--upload-pack=…` runs a command), so neither may.
|
|
8
|
+
const notAnOption = (value) => !value.startsWith('-');
|
|
9
|
+
exports.buildFileGitSourceSchema = (0, zod_1.object)({
|
|
10
|
+
git: (0, zod_1.string)()
|
|
11
|
+
.min(1)
|
|
12
|
+
.refine(notAnOption, 'git must be a repository URL or path')
|
|
13
|
+
.describe('Git repository URL or path'),
|
|
14
|
+
// branch, tag or commit SHA; the repository's HEAD when omitted
|
|
15
|
+
ref: (0, zod_1.string)()
|
|
16
|
+
.min(1)
|
|
17
|
+
.refine(notAnOption, 'ref must be a branch, tag or commit SHA')
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Branch, tag or commit SHA, the repository HEAD when omitted'),
|
|
20
|
+
// subpath of the build file or its directory within the repository
|
|
21
|
+
path: (0, zod_1.string)().optional().describe('Build file or directory within the repository'),
|
|
22
|
+
// overrides for the top-level `envs` the included file declares as its inputs
|
|
23
|
+
with: envs_schema_1.envsSchema.optional(),
|
|
24
|
+
}).strict();
|
|
25
|
+
// A string is a local path; an object is a git source (ADR-0004).
|
|
26
|
+
exports.buildFileIncludeSchema = (0, zod_1.union)([(0, zod_1.string)(), exports.buildFileGitSourceSchema]);
|
|
27
|
+
function isGitSource(include) {
|
|
28
|
+
return typeof include !== 'string';
|
|
29
|
+
}
|
|
30
|
+
exports.isGitSource = isGitSource;
|
|
31
|
+
//# sourceMappingURL=build-file-include-schema.js.map
|
|
@@ -8,12 +8,13 @@ const build_file_environment_schema_1 = require("./build-file-environment-schema
|
|
|
8
8
|
const build_file_task_schema_1 = require("./build-file-task-schema");
|
|
9
9
|
const labels_schema_1 = require("./labels-schema");
|
|
10
10
|
const cache_schema_1 = require("./cache-schema");
|
|
11
|
+
const build_file_include_schema_1 = require("./build-file-include-schema");
|
|
11
12
|
exports.buildFileSchema = (0, zod_1.object)({
|
|
12
13
|
envs: envs_schema_1.envsSchema.optional(),
|
|
13
14
|
tasks: (0, zod_1.record)(build_file_task_schema_1.buildFileTaskSchema).optional(),
|
|
14
15
|
services: (0, zod_1.record)(build_file_service_schema_1.buildFileServiceSchema).optional(),
|
|
15
|
-
references: (0, zod_1.record)(
|
|
16
|
-
includes: (0, zod_1.record)(
|
|
16
|
+
references: (0, zod_1.record)(build_file_include_schema_1.buildFileIncludeSchema).optional(),
|
|
17
|
+
includes: (0, zod_1.record)(build_file_include_schema_1.buildFileIncludeSchema).optional(),
|
|
17
18
|
environments: (0, zod_1.record)(build_file_environment_schema_1.buildFileEnvironmentSchema).optional(),
|
|
18
19
|
caches: (0, zod_1.record)(cache_schema_1.buildFileCacheSchema).optional(),
|
|
19
20
|
labels: labels_schema_1.labelsSchema.optional(),
|
|
@@ -6,6 +6,8 @@ const read_build_file_1 = require("../parser/read-build-file");
|
|
|
6
6
|
const path_1 = require("path");
|
|
7
7
|
const default_build_file_1 = require("../parser/default-build-file");
|
|
8
8
|
const parse_error_1 = require("./parse-error");
|
|
9
|
+
const build_file_include_schema_1 = require("./build-file-include-schema");
|
|
10
|
+
const resolve_git_source_1 = require("../includes/resolve-git-source");
|
|
9
11
|
async function createParseContext(fileName, environment) {
|
|
10
12
|
const ctx = {
|
|
11
13
|
files: {},
|
|
@@ -14,9 +16,10 @@ async function createParseContext(fileName, environment) {
|
|
|
14
16
|
return { ctx, scope };
|
|
15
17
|
}
|
|
16
18
|
exports.createParseContext = createParseContext;
|
|
17
|
-
async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix) {
|
|
19
|
+
async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix, remote, inputs) {
|
|
18
20
|
const relativeName = namePrefix.join(':');
|
|
19
|
-
|
|
21
|
+
// the same file included with other inputs is another set of tasks
|
|
22
|
+
const key = inputs ? `${cwd}:${fileName}:${JSON.stringify(inputs)}` : `${cwd}:${fileName}`;
|
|
20
23
|
if (ctx.files[key]) {
|
|
21
24
|
return ctx.files[key];
|
|
22
25
|
}
|
|
@@ -26,19 +29,18 @@ async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix) {
|
|
|
26
29
|
const scope = {
|
|
27
30
|
fileName,
|
|
28
31
|
cwd,
|
|
29
|
-
schema: result.data,
|
|
32
|
+
schema: inputs ? applyInputs(result.data, inputs, namePrefix, fileName) : result.data,
|
|
30
33
|
namePrefix: relativeName,
|
|
34
|
+
remote,
|
|
31
35
|
references: {},
|
|
32
36
|
};
|
|
33
37
|
ctx.files[key] = scope;
|
|
34
38
|
if (scope.schema.references) {
|
|
35
39
|
for (const referenceName of Object.keys(scope.schema.references)) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
referenceName,
|
|
41
|
-
]);
|
|
40
|
+
const target = await locateInclude(scope, scope.schema.references[referenceName],
|
|
41
|
+
// a remote file's paths resolve within its repository
|
|
42
|
+
remote ? (0, path_1.dirname)(scope.fileName) : scope.cwd, environment);
|
|
43
|
+
const referenceScope = await appendBuildFile((0, path_1.dirname)(target.fileName), ctx, environment, target.fileName, [...namePrefix, referenceName], target.remote, target.inputs);
|
|
42
44
|
if (scope.references[referenceName]) {
|
|
43
45
|
throw new Error(referenceName + ' already exists');
|
|
44
46
|
}
|
|
@@ -47,9 +49,8 @@ async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix) {
|
|
|
47
49
|
}
|
|
48
50
|
if (scope.schema.includes) {
|
|
49
51
|
for (const includeName of Object.keys(scope.schema.includes)) {
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const includeScope = await appendBuildFile(cwd, ctx, environment, includeFilename, [...namePrefix, includeName]);
|
|
52
|
+
const target = await locateInclude(scope, scope.schema.includes[includeName], (0, path_1.dirname)(scope.fileName), environment);
|
|
53
|
+
const includeScope = await appendBuildFile(cwd, ctx, environment, target.fileName, [...namePrefix, includeName], target.remote, target.inputs);
|
|
53
54
|
if (scope.references[includeName]) {
|
|
54
55
|
throw new Error(includeName + ' already exists');
|
|
55
56
|
}
|
|
@@ -63,4 +64,35 @@ async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix) {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
exports.appendBuildFile = appendBuildFile;
|
|
67
|
+
// A string is a local path below `base`; an object is a git source, fetched once
|
|
68
|
+
// into the include cache. Inside a cached checkout a local path may not leave it:
|
|
69
|
+
// a remote file contributes definitions from its own repository only.
|
|
70
|
+
async function locateInclude(scope, include, base, environment) {
|
|
71
|
+
if ((0, build_file_include_schema_1.isGitSource)(include)) {
|
|
72
|
+
const remote = await (0, resolve_git_source_1.resolveGitSource)(include, environment);
|
|
73
|
+
const path = (0, path_1.join)(remote.root, include.path ?? '');
|
|
74
|
+
if (!(0, resolve_git_source_1.isWithin)(remote.root, path)) {
|
|
75
|
+
throw new Error(`${include.path} in ${include.git} points outside the repository`);
|
|
76
|
+
}
|
|
77
|
+
return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote, inputs: include.with };
|
|
78
|
+
}
|
|
79
|
+
const path = (0, path_1.join)(base, include);
|
|
80
|
+
if (scope.remote && !(0, resolve_git_source_1.isWithin)(scope.remote.root, path)) {
|
|
81
|
+
throw new Error(`${include} in ${scope.fileName} points outside ${scope.remote.git}`);
|
|
82
|
+
}
|
|
83
|
+
return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote: scope.remote };
|
|
84
|
+
}
|
|
85
|
+
// The top-level `envs` of an included file are its inputs: it declares each one
|
|
86
|
+
// with a default, and `with` replaces the default. A name the file does not
|
|
87
|
+
// declare is rejected, so a typo cannot silently do nothing.
|
|
88
|
+
function applyInputs(schema, inputs, namePrefix, fileName) {
|
|
89
|
+
const declared = Object.keys(schema.envs ?? {});
|
|
90
|
+
for (const name of Object.keys(inputs)) {
|
|
91
|
+
if (!declared.includes(name)) {
|
|
92
|
+
const available = declared.length > 0 ? `available inputs: ${declared.join(', ')}` : 'it declares no inputs';
|
|
93
|
+
throw new Error(`unknown input ${name} for ${namePrefix.join(':')} (${fileName}), ${available}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { ...schema, envs: { ...schema.envs, ...inputs } };
|
|
97
|
+
}
|
|
66
98
|
//# sourceMappingURL=schema-parser.js.map
|
package/package.json
CHANGED