@xemahq/biome-bundle-pack 0.0.0-stage → 0.2.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/LICENSE +175 -0
- package/README.md +15 -2
- package/package.json +52 -4
- package/src/biome-content.mjs +49 -0
- package/src/bundle-archive.mjs +140 -0
- package/src/bundle-archive.test.mjs +97 -0
- package/src/bundle-build-source.mjs +58 -0
- package/src/bundle-build-source.test.mjs +53 -0
- package/src/bundle-content.mjs +30 -0
- package/src/bundle-manifest.mjs +27 -0
- package/src/bundle-unpack.mjs +114 -0
- package/src/bundle-unpack.test.mjs +63 -0
- package/src/canonical-json.mjs +9 -0
- package/src/host-provided.mjs +160 -0
- package/src/host-provided.test.mjs +128 -0
- package/src/member-required-env.mjs +32 -0
- package/src/pack-directory.mjs +148 -0
- package/src/pack-directory.test.mjs +144 -0
- package/src/seal-bundle.mjs +38 -0
- package/src/stage.mjs +60 -0
- package/src/stage.test.mjs +36 -0
- package/src/target-platform.mjs +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Xema Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
|
|
4
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
5
|
+
|
|
6
|
+
-----------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
Parameters
|
|
9
|
+
|
|
10
|
+
Licensor: Neuralchowder Inc.
|
|
11
|
+
|
|
12
|
+
Licensed Work: Xema. The Licensed Work is (c) 2026 Neuralchowder Inc.
|
|
13
|
+
|
|
14
|
+
The Licensed Work includes, without limitation, Xema Core, Xema Runtime,
|
|
15
|
+
Xema Kernel, Xema control plane, Xema application runtime, Xema biome
|
|
16
|
+
runtime, Xema biome registry, Xema store services, Xema orchestration
|
|
17
|
+
services, Xema agents, Xema skills, Xema memory services, Xema APIs, Xema
|
|
18
|
+
user interfaces, Xema command-line tools, Xema deployment artifacts, Xema
|
|
19
|
+
templates, Xema specifications, Xema schemas, Xema manifests, Xema
|
|
20
|
+
documentation, Xema examples, and all source code, object code, binaries,
|
|
21
|
+
packages, modules, configuration files, manifests, schemas, examples,
|
|
22
|
+
templates, documentation, and derivative works included in or distributed
|
|
23
|
+
as part of the Xema software project.
|
|
24
|
+
|
|
25
|
+
The Licensed Work does not include any third-party software, libraries,
|
|
26
|
+
dependencies, packages, assets, or components that are distributed with the
|
|
27
|
+
Licensed Work but are governed by their own separate license terms. Such
|
|
28
|
+
third-party materials remain subject to their respective licenses.
|
|
29
|
+
|
|
30
|
+
A specific file, package, component, or directory may state that it is
|
|
31
|
+
governed by a different license. In that case, that specific file, package,
|
|
32
|
+
component, or directory is governed by the license expressly stated therein.
|
|
33
|
+
|
|
34
|
+
Additional Use Grant:
|
|
35
|
+
|
|
36
|
+
You may make production use of the Licensed Work, provided that your use
|
|
37
|
+
does not include offering, providing, making available, distributing,
|
|
38
|
+
hosting, managing, embedding, reselling, white-labeling, sublicensing,
|
|
39
|
+
commercializing, or otherwise making the Licensed Work, or any modified
|
|
40
|
+
version or derivative work of the Licensed Work, available to Third Parties
|
|
41
|
+
as part of a Competitive Offering.
|
|
42
|
+
|
|
43
|
+
The following definitions and clarifications form part of, and govern
|
|
44
|
+
the interpretation of, the Additional Use Grant set out above.
|
|
45
|
+
|
|
46
|
+
1. "Production Use" means any use of the Licensed Work other than for
|
|
47
|
+
development, testing, evaluation, demonstration, experimentation,
|
|
48
|
+
benchmarking, or other non-production purposes.
|
|
49
|
+
|
|
50
|
+
2. "Affiliate" means any entity that, directly or indirectly,
|
|
51
|
+
controls, is controlled by, or is under common control with you,
|
|
52
|
+
where "control" means ownership of more than fifty percent (50%) of
|
|
53
|
+
the voting interests of the entity, or the power to direct its
|
|
54
|
+
management.
|
|
55
|
+
|
|
56
|
+
3. "Third Party" means any natural person or legal entity other than
|
|
57
|
+
you and your Affiliates.
|
|
58
|
+
|
|
59
|
+
4. "Competitive Offering" means any product or service, made available
|
|
60
|
+
to one or more Third Parties for a fee or other consideration
|
|
61
|
+
(including without limitation on a paid, subscription,
|
|
62
|
+
advertising-supported, hosted, or managed-service basis), that is
|
|
63
|
+
offered or marketed as, or that competes with, a software platform,
|
|
64
|
+
runtime, orchestration system, agent or automation system,
|
|
65
|
+
extension or distribution marketplace, or other software system
|
|
66
|
+
that provides, in substantial part, the same or substantially
|
|
67
|
+
similar capabilities as those provided by the Licensed Work. A
|
|
68
|
+
product or service is a Competitive Offering whether the Licensed
|
|
69
|
+
Work is provided in whole or in part, alone or bundled with other
|
|
70
|
+
software, and whether modified or unmodified.
|
|
71
|
+
|
|
72
|
+
5. Providing, hosting, or managing the Licensed Work for a Third Party,
|
|
73
|
+
or otherwise making the Licensed Work available to a Third Party as
|
|
74
|
+
a hosted or managed service such that the Third Party obtains the
|
|
75
|
+
principal value or functionality of the Licensed Work, is a
|
|
76
|
+
Competitive Offering and is not permitted by the Additional Use
|
|
77
|
+
Grant.
|
|
78
|
+
|
|
79
|
+
6. Subject to the foregoing, the Additional Use Grant permits you to
|
|
80
|
+
make Production Use of the Licensed Work for your own internal
|
|
81
|
+
business purposes and to build, operate, and provide your own
|
|
82
|
+
products and services to Third Parties, provided that the Licensed
|
|
83
|
+
Work itself (whether modified or unmodified) is not the product or
|
|
84
|
+
service, and is not a Competitive Offering, made available to those
|
|
85
|
+
Third Parties.
|
|
86
|
+
|
|
87
|
+
7. You may modify the Licensed Work and create derivative works for
|
|
88
|
+
any purpose permitted by this License. Any Production Use of such
|
|
89
|
+
modified or derivative works remains subject to the same
|
|
90
|
+
restrictions as the Licensed Work, including the prohibition on
|
|
91
|
+
Competitive Offerings.
|
|
92
|
+
|
|
93
|
+
8. Use of the Licensed Work by your own employees, contractors, and
|
|
94
|
+
agents, acting on your behalf and for your benefit, is not by
|
|
95
|
+
itself making the Licensed Work available to a Third Party.
|
|
96
|
+
|
|
97
|
+
9. Embedding or distributing the Licensed Work as a component of your
|
|
98
|
+
own product or service is permitted only where the Licensed Work
|
|
99
|
+
does not constitute the principal value of that product or service
|
|
100
|
+
and the product or service is not a Competitive Offering.
|
|
101
|
+
|
|
102
|
+
10. Nothing in this Additional Use Grant grants any right to use any
|
|
103
|
+
name, trademark, service mark, or logo of the Licensor or its
|
|
104
|
+
Affiliates, except as reasonably required for customary
|
|
105
|
+
attribution.
|
|
106
|
+
|
|
107
|
+
11. The rights stated in this Additional Use Grant are the maximum
|
|
108
|
+
rights granted for Production Use prior to the Change Date. Any
|
|
109
|
+
Production Use not expressly permitted requires a separate
|
|
110
|
+
commercial license from the Licensor.
|
|
111
|
+
|
|
112
|
+
12. If any provision of this Additional Use Grant is held invalid or
|
|
113
|
+
unenforceable, that provision shall be limited or eliminated to
|
|
114
|
+
the minimum extent necessary so that the remainder of this
|
|
115
|
+
Additional Use Grant, and of this License, remains in full force
|
|
116
|
+
and effect. The Licensor reserves all rights not expressly granted
|
|
117
|
+
by this License.
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
Change Date: Four years from the date the specific version of the Licensed
|
|
121
|
+
Work is first publicly released by Licensor.
|
|
122
|
+
|
|
123
|
+
Change License: Mozilla Public License, Version 2.0
|
|
124
|
+
|
|
125
|
+
For information about alternative licensing arrangements, commercial
|
|
126
|
+
licensing, enterprise licensing, OEM licensing, appliance licensing, managed
|
|
127
|
+
service licensing, cloud provider licensing, or other commercial use of the
|
|
128
|
+
Licensed Work, please contact: licensing@neuralchowder.com
|
|
129
|
+
|
|
130
|
+
-----------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
Notice
|
|
133
|
+
|
|
134
|
+
Business Source License 1.1
|
|
135
|
+
|
|
136
|
+
Terms
|
|
137
|
+
|
|
138
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
139
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
140
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
141
|
+
production use.
|
|
142
|
+
|
|
143
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
144
|
+
available distribution of a specific version of the Licensed Work under this
|
|
145
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
146
|
+
the terms of the Change License, and the rights granted in the paragraph above
|
|
147
|
+
terminate.
|
|
148
|
+
|
|
149
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
150
|
+
currently in effect as described in this License, you must purchase a
|
|
151
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
152
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
153
|
+
|
|
154
|
+
All copies of the original and modified Licensed Work, and derivative works of
|
|
155
|
+
the Licensed Work, are subject to this License. This License applies separately
|
|
156
|
+
for each version of the Licensed Work and the Change Date may vary for each
|
|
157
|
+
version of the Licensed Work released by Licensor.
|
|
158
|
+
|
|
159
|
+
You must conspicuously display this License on each original or modified copy
|
|
160
|
+
of the Licensed Work. If you receive the Licensed Work in original or modified
|
|
161
|
+
form from a third party, the terms and conditions set forth in this License
|
|
162
|
+
apply to your use of that work.
|
|
163
|
+
|
|
164
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
165
|
+
terminate your rights under this License for the current and all other
|
|
166
|
+
versions of the Licensed Work.
|
|
167
|
+
|
|
168
|
+
This License does not grant you any right in any trademark or logo of Licensor
|
|
169
|
+
or its affiliates, provided that you may use a trademark or logo of Licensor
|
|
170
|
+
as expressly required by this License.
|
|
171
|
+
|
|
172
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
|
|
173
|
+
"AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS
|
|
174
|
+
OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
175
|
+
FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xemahq/biome-bundle-pack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The core of packing a biome bundle, shared by the repository build pipeline
|
|
4
|
+
(`@xemahq/repo-build-tooling`) and the `xema` CLI, so there is ONE bundle format:
|
|
5
|
+
|
|
6
|
+
- `bundle-manifest`, `bundle-archive`, `bundle-content`, `seal-bundle`: the canonical manifest, the deterministic tar/gzip
|
|
7
|
+
archive, the content digest, and the sealing of a staged tree into `<out>/<hex>/` + `<out>/<hex>.tgz`;
|
|
8
|
+
- `bundle-unpack`: `unpackBundle`, the reader of the archive `bundle-archive` writes (digest-checked, nothing leaves the
|
|
9
|
+
target directory);
|
|
10
|
+
- `stage`, `host-provided`: `stageBundle` (production install, then the host-provided packages cut out) and the
|
|
11
|
+
standalone project install;
|
|
12
|
+
- `pack-directory`: `packBiomeDirectory`, a built biome directory packed with the same pieces
|
|
13
|
+
(`xema biome install --from dir:<path>`, `xema biome dev`);
|
|
14
|
+
- `bundle-build-source`, `target-platform`, `canonical-json`, `biome-content`.
|
|
15
|
+
|
|
16
|
+
It holds no monorepo pipeline code (no workspace re-resolve, no image builds, no Prisma proof, no registry access).
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/biome-bundle-pack",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "The pure core of packing a biome bundle: the canonical manifest, the deterministic archive, the content digest, the staged production install and the host-provided exclusion. Shared by the repository build pipeline and the xema CLI.",
|
|
5
|
+
"license": "LicenseRef-Xema-BSL-1.1",
|
|
6
|
+
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
7
|
+
"homepage": "https://xema.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/xema-dev/xema-base.git",
|
|
11
|
+
"directory": "packages/sdks/biome-bundle-pack"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"registry": "https://registry.npmjs.org/",
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"files": [
|
|
19
|
+
"README.md",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./archive": "./src/bundle-archive.mjs",
|
|
24
|
+
"./biome-content": "./src/biome-content.mjs",
|
|
25
|
+
"./build-source": "./src/bundle-build-source.mjs",
|
|
26
|
+
"./canonical-json": "./src/canonical-json.mjs",
|
|
27
|
+
"./content": "./src/bundle-content.mjs",
|
|
28
|
+
"./host-provided": "./src/host-provided.mjs",
|
|
29
|
+
"./manifest": "./src/bundle-manifest.mjs",
|
|
30
|
+
"./member-required-env": "./src/member-required-env.mjs",
|
|
31
|
+
"./pack-directory": "./src/pack-directory.mjs",
|
|
32
|
+
"./seal": "./src/seal-bundle.mjs",
|
|
33
|
+
"./stage": "./src/stage.mjs",
|
|
34
|
+
"./target-platform": "./src/target-platform.mjs",
|
|
35
|
+
"./unpack": "./src/bundle-unpack.mjs",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@xemahq/host-abi-node": "^0.2.0",
|
|
40
|
+
"@xemahq/contracts": "^0.120.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"eslint": "^9.39.4",
|
|
44
|
+
"@xemahq/eslint-config": "^0.14.3"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"lint": "eslint .",
|
|
48
|
+
"test": "node --test src/bundle-archive.test.mjs && node --test src/bundle-build-source.test.mjs && node --test src/host-provided.test.mjs && node --test src/stage.test.mjs && node --test src/bundle-unpack.test.mjs && node --test src/pack-directory.test.mjs"
|
|
49
|
+
},
|
|
50
|
+
"xemaRelease": {
|
|
51
|
+
"sourceDigest": "sha256:9dee0681ed083ba1816f3b709b76b20e76bd7b7703b6c2831d4181d2d672a1ee",
|
|
52
|
+
"sha": "9a88d6fde912be61affb481b069f009abfdfde21"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The biome's non-code content a bundle carries beside its code, at repo-relative paths: the biome manifest, its generated
|
|
2
|
+
// index when it has one, and every top-level `contributions/*.contribution.json`. The contribution kinds the bundle
|
|
3
|
+
// declares are read from those files, each graded by the contracts' own `ContributionKindRefSchema`: a file without a
|
|
4
|
+
// string `kind`, or with a kind the schema refuses, refuses the pack, naming the file.
|
|
5
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, posix } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { ContributionKindRefSchema } from '@xemahq/contracts/contribution';
|
|
9
|
+
|
|
10
|
+
const INDEX_FILE = 'xema-biome.index.json';
|
|
11
|
+
const CONTRIBUTIONS_DIRECTORY = 'contributions';
|
|
12
|
+
const CONTRIBUTION_SUFFIX = '.contribution.json';
|
|
13
|
+
|
|
14
|
+
function copyInto({ root, stage, rel }) {
|
|
15
|
+
mkdirSync(dirname(join(stage, rel)), { recursive: true });
|
|
16
|
+
copyFileSync(join(root, rel), join(stage, rel));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Copy the content of each biome (`manifests`: repository-relative `<biomeDir>/xema-biome.json` paths) into `stage`, in
|
|
21
|
+
* sorted order, and return the sorted unique contribution kinds of the contribution files. Throws once, naming every
|
|
22
|
+
* offending file.
|
|
23
|
+
*/
|
|
24
|
+
export function copyBiomeContent({ root, stage, manifests }) {
|
|
25
|
+
const kinds = new Set();
|
|
26
|
+
const problems = [];
|
|
27
|
+
for (const manifest of [...manifests].sort()) {
|
|
28
|
+
const biomeDirectory = posix.dirname(manifest);
|
|
29
|
+
copyInto({ root, stage, rel: manifest });
|
|
30
|
+
const index = posix.join(biomeDirectory, INDEX_FILE);
|
|
31
|
+
if (existsSync(join(root, index))) {copyInto({ root, stage, rel: index });}
|
|
32
|
+
const directory = join(root, biomeDirectory, CONTRIBUTIONS_DIRECTORY);
|
|
33
|
+
if (!existsSync(directory)) {continue;}
|
|
34
|
+
const names = readdirSync(directory, { withFileTypes: true })
|
|
35
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(CONTRIBUTION_SUFFIX))
|
|
36
|
+
.map((entry) => entry.name)
|
|
37
|
+
.sort();
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const rel = posix.join(biomeDirectory, CONTRIBUTIONS_DIRECTORY, name);
|
|
40
|
+
copyInto({ root, stage, rel });
|
|
41
|
+
const kind = JSON.parse(readFileSync(join(root, rel), 'utf8')).kind;
|
|
42
|
+
const parsed = typeof kind === 'string' ? ContributionKindRefSchema.safeParse(kind) : undefined;
|
|
43
|
+
if (parsed?.success === true) {kinds.add(kind);}
|
|
44
|
+
else {problems.push(`${rel}: ${parsed === undefined ? 'has no string "kind"' : `kind "${kind}" is refused: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`}`);}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (problems.length > 0) {throw new Error(`cannot pack the biome content:\n${problems.map((p) => ` - ${p}`).join('\n')}`);}
|
|
48
|
+
return [...kinds].sort();
|
|
49
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The canonical archive of a bundle: the same tree gives the same bytes on any host, at any time, in any creation
|
|
2
|
+
// order, so the sha256 of the tar is a content address. Nothing of the build host leaks in: entries are sorted bytewise by
|
|
3
|
+
// path, owners are root with empty names, every mtime is 0, modes are 0755 or 0644 (a symlink 0777), and the gzip header
|
|
4
|
+
// carries the "unknown" OS byte. Long or non-ASCII names go into pax extended headers, which every modern tar reads.
|
|
5
|
+
// A name or target that needs a pax record is also written truncated in the ustar header, as GNU tar and Python do: libarchive
|
|
6
|
+
// ignores the pax link record when the header's own link field is empty.
|
|
7
|
+
// Loads only `node:` builtins.
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { lstatSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { gzipSync } from 'node:zlib';
|
|
12
|
+
|
|
13
|
+
const BLOCK = 512;
|
|
14
|
+
const NAME_LIMIT = 100;
|
|
15
|
+
const PREFIX_LIMIT = 155;
|
|
16
|
+
const MAX_ENTRY_SIZE = 8 ** 11 - 1;
|
|
17
|
+
const GZIP_OS_OFFSET = 9;
|
|
18
|
+
const GZIP_OS_UNKNOWN = 255;
|
|
19
|
+
|
|
20
|
+
/** The ustar type flags this archive writes. */
|
|
21
|
+
const TypeFlag = Object.freeze({ File: '0', Symlink: '2', Directory: '5', Pax: 'x' });
|
|
22
|
+
|
|
23
|
+
const bytewise = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
24
|
+
const isAscii = (text) => /^[\x20-\x7e]*$/.test(text);
|
|
25
|
+
|
|
26
|
+
/** Every path under `dir` (relative, `/`-separated) with its lstat, sorted bytewise; sockets, devices and FIFOs are refused. */
|
|
27
|
+
function listEntries(dir) {
|
|
28
|
+
const found = [];
|
|
29
|
+
const refused = [];
|
|
30
|
+
const walk = (prefix) => {
|
|
31
|
+
for (const name of readdirSync(join(dir, prefix))) {
|
|
32
|
+
const rel = prefix === '' ? name : `${prefix}/${name}`;
|
|
33
|
+
const stat = lstatSync(join(dir, rel));
|
|
34
|
+
if (stat.isDirectory()) {
|
|
35
|
+
found.push({ rel, stat });
|
|
36
|
+
walk(rel);
|
|
37
|
+
} else if (stat.isFile() || stat.isSymbolicLink()) {
|
|
38
|
+
found.push({ rel, stat });
|
|
39
|
+
} else {
|
|
40
|
+
refused.push(rel);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk('');
|
|
45
|
+
if (refused.length > 0) {throw new Error(`cannot archive ${refused.length} entr${refused.length > 1 ? 'ies' : 'y'} that are neither file, directory nor symlink: ${refused.join(', ')}`);}
|
|
46
|
+
return found.sort((a, b) => bytewise(a.rel, b.rel));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const octal = (value, width) => Buffer.from(`${value.toString(8).padStart(width - 1, '0')}\0`, 'latin1');
|
|
50
|
+
|
|
51
|
+
function field(header, offset, width, value) {
|
|
52
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8');
|
|
53
|
+
bytes.copy(header, offset, 0, Math.min(bytes.length, width));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `{ name, prefix }` of a path that fits ustar's 100-byte name with a 155-byte prefix split at a `/`, else `undefined`. */
|
|
57
|
+
function splitName(path) {
|
|
58
|
+
const bytes = Buffer.from(path, 'utf8');
|
|
59
|
+
if (bytes.length <= NAME_LIMIT) {return { name: path, prefix: '' };}
|
|
60
|
+
for (let i = path.lastIndexOf('/'); i > 0; i = path.lastIndexOf('/', i - 1)) {
|
|
61
|
+
const prefix = path.slice(0, i);
|
|
62
|
+
const name = path.slice(i + 1);
|
|
63
|
+
if (Buffer.byteLength(prefix) <= PREFIX_LIMIT && Buffer.byteLength(name) <= NAME_LIMIT && name.length > 0) {return { name, prefix };}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ustarHeader({ name, prefix = '', mode, size, type, linkname = '' }) {
|
|
69
|
+
const header = Buffer.alloc(BLOCK);
|
|
70
|
+
field(header, 0, NAME_LIMIT, name);
|
|
71
|
+
field(header, 100, 8, octal(mode, 8));
|
|
72
|
+
field(header, 108, 8, octal(0, 8));
|
|
73
|
+
field(header, 116, 8, octal(0, 8));
|
|
74
|
+
field(header, 124, 12, octal(size, 12));
|
|
75
|
+
field(header, 136, 12, octal(0, 12));
|
|
76
|
+
header.fill(0x20, 148, 156);
|
|
77
|
+
field(header, 156, 1, type);
|
|
78
|
+
field(header, 157, NAME_LIMIT, linkname);
|
|
79
|
+
field(header, 257, 6, 'ustar\0');
|
|
80
|
+
field(header, 263, 2, '00');
|
|
81
|
+
field(header, 345, PREFIX_LIMIT, prefix);
|
|
82
|
+
let sum = 0;
|
|
83
|
+
for (const byte of header) {sum += byte;}
|
|
84
|
+
field(header, 148, 8, Buffer.from(`${sum.toString(8).padStart(6, '0')}\0 `, 'latin1'));
|
|
85
|
+
return header;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One pax record `"<length> <key>=<value>\n"`, the length counting its own digits. */
|
|
89
|
+
function paxRecord(key, value) {
|
|
90
|
+
const body = ` ${key}=${value}\n`;
|
|
91
|
+
let length = Buffer.byteLength(body);
|
|
92
|
+
while (String(length).length + Buffer.byteLength(body) !== length) {length = String(length).length + Buffer.byteLength(body);}
|
|
93
|
+
return Buffer.from(`${length}${body}`, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const padded = (data) => Buffer.concat([data, Buffer.alloc((BLOCK - (data.length % BLOCK)) % BLOCK)]);
|
|
97
|
+
|
|
98
|
+
/** The header block(s) of one entry: a pax header first whenever the name or link target does not fit plain ustar. */
|
|
99
|
+
function entryHeaders({ path, mode, size, type, linkname }) {
|
|
100
|
+
const split = isAscii(path) ? splitName(path) : undefined;
|
|
101
|
+
const linkFits = isAscii(linkname) && Buffer.byteLength(linkname) <= NAME_LIMIT;
|
|
102
|
+
const records = [];
|
|
103
|
+
if (split === undefined) {records.push(paxRecord('path', path));}
|
|
104
|
+
if (!linkFits) {records.push(paxRecord('linkpath', linkname));}
|
|
105
|
+
const plain = split ?? { name: Buffer.from(path, 'utf8').subarray(0, NAME_LIMIT).toString('latin1'), prefix: '' };
|
|
106
|
+
const real = ustarHeader({ ...plain, mode, size, type, linkname: Buffer.from(linkname, 'utf8').subarray(0, NAME_LIMIT) });
|
|
107
|
+
if (records.length === 0) {return [real];}
|
|
108
|
+
const pax = Buffer.concat(records);
|
|
109
|
+
return [ustarHeader({ name: 'PaxHeader', mode: 0o644, size: pax.length, type: TypeFlag.Pax }), padded(pax), real];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The POSIX tar of every entry under `dir` (not `dir` itself), canonical as the header says. Refuses sockets, devices and FIFOs. */
|
|
113
|
+
export function canonicalTar(dir) {
|
|
114
|
+
const parts = [];
|
|
115
|
+
for (const { rel, stat } of listEntries(dir)) {
|
|
116
|
+
if (stat.isDirectory()) {
|
|
117
|
+
parts.push(...entryHeaders({ path: `${rel}/`, mode: 0o755, size: 0, type: TypeFlag.Directory, linkname: '' }));
|
|
118
|
+
} else if (stat.isSymbolicLink()) {
|
|
119
|
+
parts.push(...entryHeaders({ path: rel, mode: 0o777, size: 0, type: TypeFlag.Symlink, linkname: readlinkSync(join(dir, rel)) }));
|
|
120
|
+
} else {
|
|
121
|
+
if (stat.size > MAX_ENTRY_SIZE) {throw new Error(`${rel} is ${stat.size} bytes, more than a tar entry holds`);}
|
|
122
|
+
const mode = (stat.mode & 0o111) === 0 ? 0o644 : 0o755;
|
|
123
|
+
parts.push(...entryHeaders({ path: rel, mode, size: stat.size, type: TypeFlag.File, linkname: '' }), padded(readFileSync(join(dir, rel))));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
parts.push(Buffer.alloc(BLOCK * 2));
|
|
127
|
+
return Buffer.concat(parts);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The tar gzipped at level 9 with the header's OS byte fixed, so the bytes do not depend on the build host. */
|
|
131
|
+
export function gzipCanonical(tarBuffer) {
|
|
132
|
+
const gz = gzipSync(tarBuffer, { level: 9 });
|
|
133
|
+
gz[GZIP_OS_OFFSET] = GZIP_OS_UNKNOWN;
|
|
134
|
+
return gz;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `sha256:<hex>` of the archive bytes (the `.tgz` `gzipCanonical` made): the bundle's address. */
|
|
138
|
+
export function archiveDigest(archiveBytes) {
|
|
139
|
+
return `sha256:${createHash('sha256').update(archiveBytes).digest('hex')}`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { gunzipSync } from 'node:zlib';
|
|
8
|
+
|
|
9
|
+
import { archiveDigest, canonicalTar, gzipCanonical } from './bundle-archive.mjs';
|
|
10
|
+
|
|
11
|
+
const scratch = () => fs.mkdtempSync(path.join(os.tmpdir(), 'xema-archive-'));
|
|
12
|
+
|
|
13
|
+
function put(root, rel, content, mode) {
|
|
14
|
+
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(root, rel), content);
|
|
16
|
+
if (mode !== undefined) {fs.chmodSync(path.join(root, rel), mode);}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('the same tree gives the same bytes whatever the mtimes, the creation order and the non-exec modes', () => {
|
|
20
|
+
const a = scratch();
|
|
21
|
+
const b = scratch();
|
|
22
|
+
put(a, 'x/one.txt', '1', 0o644);
|
|
23
|
+
put(a, 'y/two.txt', '2', 0o600);
|
|
24
|
+
put(a, 'run.sh', '#!/bin/sh\n', 0o755);
|
|
25
|
+
fs.symlinkSync('x/one.txt', path.join(a, 'link'));
|
|
26
|
+
put(b, 'run.sh', '#!/bin/sh\n', 0o700);
|
|
27
|
+
put(b, 'y/two.txt', '2', 0o664);
|
|
28
|
+
fs.symlinkSync('x/one.txt', path.join(b, 'link'));
|
|
29
|
+
put(b, 'x/one.txt', '1', 0o640);
|
|
30
|
+
fs.utimesSync(path.join(b, 'x/one.txt'), 1000, 1000);
|
|
31
|
+
assert.ok(canonicalTar(a).equals(canonicalTar(b)));
|
|
32
|
+
assert.equal(archiveDigest(gzipCanonical(canonicalTar(a))), archiveDigest(gzipCanonical(canonicalTar(b))));
|
|
33
|
+
assert.match(archiveDigest(gzipCanonical(canonicalTar(a))), /^sha256:[0-9a-f]{64}$/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a changed file byte, or a changed exec bit, changes the digest', () => {
|
|
37
|
+
const a = scratch();
|
|
38
|
+
put(a, 'f', 'abc', 0o644);
|
|
39
|
+
const before = archiveDigest(gzipCanonical(canonicalTar(a)));
|
|
40
|
+
put(a, 'f', 'abd', 0o644);
|
|
41
|
+
const edited = archiveDigest(gzipCanonical(canonicalTar(a)));
|
|
42
|
+
assert.notEqual(edited, before);
|
|
43
|
+
fs.chmodSync(path.join(a, 'f'), 0o755);
|
|
44
|
+
assert.notEqual(archiveDigest(gzipCanonical(canonicalTar(a))), edited);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a path over 255 bytes and a long symlink target survive the system tar', () => {
|
|
48
|
+
const dir = scratch();
|
|
49
|
+
const deep = `${'d'.repeat(90)}/${'e'.repeat(90)}/${'f'.repeat(90)}`;
|
|
50
|
+
const file = `${deep}/${'n'.repeat(60)}.txt`;
|
|
51
|
+
const target = `../${'t'.repeat(150)}/target`;
|
|
52
|
+
const unicode = 'café/über.txt';
|
|
53
|
+
put(dir, file, 'long');
|
|
54
|
+
put(dir, unicode, 'u');
|
|
55
|
+
fs.symlinkSync(target, path.join(dir, 'link'));
|
|
56
|
+
const tarFile = path.join(scratch(), 'bundle.tar');
|
|
57
|
+
fs.writeFileSync(tarFile, canonicalTar(dir));
|
|
58
|
+
const listing = execFileSync('tar', ['-tvf', tarFile], { encoding: 'utf8' });
|
|
59
|
+
assert.ok(listing.includes(file), 'long path listed');
|
|
60
|
+
// The non-ASCII name is proven by extracting it below, not by the listing: a
|
|
61
|
+
// system tar prints non-ASCII bytes as octal escapes unless the locale is
|
|
62
|
+
// UTF-8, and turbo's strict env hands the task no locale at all.
|
|
63
|
+
const out = scratch();
|
|
64
|
+
execFileSync('tar', ['-xf', tarFile, '-C', out]);
|
|
65
|
+
assert.equal(fs.readFileSync(path.join(out, file), 'utf8'), 'long');
|
|
66
|
+
assert.equal(fs.readFileSync(path.join(out, unicode), 'utf8'), 'u');
|
|
67
|
+
assert.equal(fs.readlinkSync(path.join(out, 'link')), target);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('gzipCanonical fixes the OS byte and gunzips to the tar', () => {
|
|
71
|
+
const dir = scratch();
|
|
72
|
+
put(dir, 'f', 'abc');
|
|
73
|
+
const tar = canonicalTar(dir);
|
|
74
|
+
const gz = gzipCanonical(tar);
|
|
75
|
+
assert.equal(gz[9], 255);
|
|
76
|
+
assert.ok(gunzipSync(gz).equals(tar));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('entries are sorted bytewise and owned by root with mtime 0', () => {
|
|
80
|
+
const dir = scratch();
|
|
81
|
+
put(dir, 'b', '');
|
|
82
|
+
put(dir, 'a-b', '');
|
|
83
|
+
put(dir, 'a/c', '');
|
|
84
|
+
const tarFile = path.join(scratch(), 'bundle.tar');
|
|
85
|
+
fs.writeFileSync(tarFile, canonicalTar(dir));
|
|
86
|
+
const names = execFileSync('tar', ['-tf', tarFile], { encoding: 'utf8' }).trim().split('\n');
|
|
87
|
+
assert.deepEqual(names, ['a/', 'a-b', 'a/c', 'b']);
|
|
88
|
+
const header = canonicalTar(dir);
|
|
89
|
+
assert.equal(header.subarray(108, 116).toString('latin1'), '0000000\0');
|
|
90
|
+
assert.equal(header.subarray(136, 148).toString('latin1'), '00000000000\0');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a FIFO is refused', () => {
|
|
94
|
+
const dir = scratch();
|
|
95
|
+
execFileSync('mkfifo', [path.join(dir, 'pipe')]);
|
|
96
|
+
assert.throws(() => canonicalTar(dir), /neither file, directory nor symlink: pipe/);
|
|
97
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Where a bundle's code was built: one repository at one commit, recorded in `xema-bundle.json` so Biome Host can copy it into
|
|
2
|
+
// the member facts. The library reads no environment and installs nothing: the process entry reads `GITHUB_*` once and passes
|
|
3
|
+
// `ci` down; a local pack reads the git checkout through the injected `exec`. A tree that is not a clean checkout is refused
|
|
4
|
+
// unless the caller states the source explicitly (`--build-source <repository>@<commit>`).
|
|
5
|
+
import { ProcessMemberBuildSourceSchema } from '@xemahq/contracts/process-topology';
|
|
6
|
+
|
|
7
|
+
/** The `ci` argument of `resolveBuildSource` from a process environment: the GitHub Actions repository and sha, or `undefined` outside Actions. Process entries only. */
|
|
8
|
+
export function ciBuildSource(env) {
|
|
9
|
+
if (env.GITHUB_ACTIONS !== 'true') {return undefined;}
|
|
10
|
+
const repository = env.GITHUB_REPOSITORY;
|
|
11
|
+
const commit = env.GITHUB_SHA;
|
|
12
|
+
if (!repository || !commit) {throw new Error('GITHUB_ACTIONS is set but GITHUB_REPOSITORY or GITHUB_SHA is not: pass --build-source <repository>@<commit>');}
|
|
13
|
+
return { repository, commit };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `<repository>@<commit>` as a build source. The commit follows the LAST `@`, so an scp-style `git@host:path` repository is allowed. */
|
|
17
|
+
export function parseBuildSource(text) {
|
|
18
|
+
const at = text.lastIndexOf('@');
|
|
19
|
+
const repository = at < 0 ? '' : text.slice(0, at);
|
|
20
|
+
const commit = at < 0 ? '' : text.slice(at + 1);
|
|
21
|
+
if (repository.length === 0 || !ProcessMemberBuildSourceSchema.safeParse({ repository: 'r', commit }).success) {
|
|
22
|
+
throw new Error(`--build-source must be <repository>@<40-hex commit>, got "${text}"`);
|
|
23
|
+
}
|
|
24
|
+
return { repository, commit };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A GitHub remote (`https://github.com/o/r(.git)`, `git@github.com:o/r(.git)`, `ssh://git@github.com/o/r`) as `o/r`, the form CI records; any other remote is kept as written. */
|
|
28
|
+
export function repositoryName(remote) {
|
|
29
|
+
const match = /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/git@|git@)github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/u.exec(remote.trim());
|
|
30
|
+
return match === null ? remote.trim() : match[1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function git(exec, root, args) {
|
|
34
|
+
const result = exec('git', args, { cwd: root });
|
|
35
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The build source of the git checkout at `root`: its `origin` URL and `HEAD`. Refuses a missing checkout, a missing origin and a dirty tree (unless `allowDirty`: the content digest, not the commit, identifies a dev tree's bytes). */
|
|
39
|
+
export function localBuildSource({ root, exec, allowDirty = false }) {
|
|
40
|
+
const head = git(exec, root, ['rev-parse', 'HEAD']);
|
|
41
|
+
if (head === undefined) {throw new Error(`${root} is not a git checkout: pass --build-source <repository>@<commit>`);}
|
|
42
|
+
const origin = git(exec, root, ['remote', 'get-url', 'origin']);
|
|
43
|
+
if (origin === undefined || origin === '') {throw new Error(`${root} has no origin remote: pass --build-source <repository>@<commit>`);}
|
|
44
|
+
const status = git(exec, root, ['status', '--porcelain']);
|
|
45
|
+
if (status === undefined) {throw new Error(`cannot read the git status of ${root}: pass --build-source <repository>@<commit>`);}
|
|
46
|
+
if (status !== '' && !allowDirty) {throw new Error(`${root} has uncommitted changes, so its HEAD does not describe what is packed: commit them or pass --build-source <repository>@<commit>`);}
|
|
47
|
+
return { repository: repositoryName(origin), commit: head };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The build source to record: `explicit` (`<repository>@<commit>`) when given, else `ci` (what the entry read from GitHub Actions),
|
|
52
|
+
* else the local checkout at `root`. Every source is graded: the commit must be a full lowercase 40-hex git commit.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveBuildSource({ root, explicit, ci, exec, allowDirty = false }) {
|
|
55
|
+
const source = explicit !== undefined ? parseBuildSource(explicit) : ci ?? localBuildSource({ root, exec, allowDirty });
|
|
56
|
+
if (!ProcessMemberBuildSourceSchema.safeParse(source).success) {throw new Error(`build source commit "${source.commit}" is not a full lowercase 40-hex git commit`);}
|
|
57
|
+
return source;
|
|
58
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { ciBuildSource, localBuildSource, parseBuildSource, repositoryName, resolveBuildSource } from './bundle-build-source.mjs';
|
|
5
|
+
|
|
6
|
+
const COMMIT = 'a'.repeat(40);
|
|
7
|
+
|
|
8
|
+
/** A git stand-in: `answers` maps the subcommand (`rev-parse`, `remote`, `status`) to stdout, or `null` for a failing exit. */
|
|
9
|
+
const gitExec = (answers) => (file, args) => {
|
|
10
|
+
assert.equal(file, 'git');
|
|
11
|
+
const answer = answers[args[0]];
|
|
12
|
+
return answer === null || answer === undefined ? { status: 128, stdout: '', stderr: 'fatal' } : { status: 0, stdout: `${answer}\n`, stderr: '' };
|
|
13
|
+
};
|
|
14
|
+
const clean = { 'rev-parse': COMMIT, remote: 'git@github.com:xema-dev/xema-base.git', status: '' };
|
|
15
|
+
|
|
16
|
+
test('a local checkout gives its origin and HEAD', () => {
|
|
17
|
+
assert.deepEqual(localBuildSource({ root: '/r', exec: gitExec(clean) }), { repository: 'xema-dev/xema-base', commit: COMMIT });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('outside a checkout, without an origin or with a dirty tree the pack is refused and names the override', () => {
|
|
21
|
+
assert.throws(() => localBuildSource({ root: '/r', exec: gitExec({ ...clean, 'rev-parse': null }) }), /not a git checkout.*--build-source/);
|
|
22
|
+
assert.throws(() => localBuildSource({ root: '/r', exec: gitExec({ ...clean, remote: null }) }), /no origin remote/);
|
|
23
|
+
assert.throws(() => localBuildSource({ root: '/r', exec: gitExec({ ...clean, status: ' M a.txt' }) }), /uncommitted changes.*--build-source/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('an explicit source wins over CI and the checkout; CI wins over the checkout', () => {
|
|
27
|
+
const never = () => { throw new Error('git must not run'); };
|
|
28
|
+
const ci = { repository: 'o/r', commit: COMMIT };
|
|
29
|
+
assert.deepEqual(resolveBuildSource({ root: '/r', explicit: `o/x@${'b'.repeat(40)}`, ci, exec: never }), { repository: 'o/x', commit: 'b'.repeat(40) });
|
|
30
|
+
assert.deepEqual(resolveBuildSource({ root: '/r', ci, exec: never }), ci);
|
|
31
|
+
assert.deepEqual(resolveBuildSource({ root: '/r', exec: gitExec(clean) }), { repository: 'xema-dev/xema-base', commit: COMMIT });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an explicit source on a dirty tree is accepted', () => {
|
|
35
|
+
assert.deepEqual(resolveBuildSource({ root: '/r', explicit: `o/r@${COMMIT}`, exec: gitExec({ ...clean, status: ' M a' }) }), { repository: 'o/r', commit: COMMIT });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('--build-source needs a repository and a full lowercase 40-hex commit', () => {
|
|
39
|
+
for (const bad of ['o/r', 'o/r@abc', `@${COMMIT}`, `o/r@${'A'.repeat(40)}`]) {assert.throws(() => parseBuildSource(bad), /--build-source must be/);}
|
|
40
|
+
assert.deepEqual(parseBuildSource(`git@host:o/r@${COMMIT}`), { repository: 'git@host:o/r', commit: COMMIT });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('CI is read from GitHub Actions only, and half of it is refused', () => {
|
|
44
|
+
assert.equal(ciBuildSource({}), undefined);
|
|
45
|
+
assert.equal(ciBuildSource({ GITHUB_REPOSITORY: 'o/r', GITHUB_SHA: COMMIT }), undefined);
|
|
46
|
+
assert.deepEqual(ciBuildSource({ GITHUB_ACTIONS: 'true', GITHUB_REPOSITORY: 'o/r', GITHUB_SHA: COMMIT }), { repository: 'o/r', commit: COMMIT });
|
|
47
|
+
assert.throws(() => ciBuildSource({ GITHUB_ACTIONS: 'true', GITHUB_REPOSITORY: 'o/r' }), /GITHUB_SHA/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('GitHub remotes become owner/repo; others are kept', () => {
|
|
51
|
+
for (const remote of ['https://github.com/o/r.git', 'https://tok@github.com/o/r', 'git@github.com:o/r.git', 'ssh://git@github.com/o/r']) {assert.equal(repositoryName(remote), 'o/r');}
|
|
52
|
+
assert.equal(repositoryName('https://git.example.com/o/r.git'), 'https://git.example.com/o/r.git');
|
|
53
|
+
});
|