tessello 0.1.0 → 0.2.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/README.md +27 -0
- package/cli.js +26 -2
- package/listing.js +189 -0
- package/package.json +3 -2
- package/publish-command.js +206 -0
package/README.md
CHANGED
|
@@ -9,3 +9,30 @@ npx tessello validate my-module --json # the report as JSON
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
It checks the Module against the Tessello [Convention](https://github.com/craftskillz/tessello/blob/main/convention/CONVENTION.md) and exits with 1 when the Module is not installable, 0 otherwise. Every error and warning names the file and says what to change: fix them until there is no error left, then zip the *contents* of the folder (not the folder itself).
|
|
12
|
+
|
|
13
|
+
## Publishing a Module
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx tessello publish my-module.zip --url https://me.github.io/modules/my-module-1.0.0.zip --accept-terms
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Host the `.zip` somewhere first, then run this. `tessello publish` prepares the Marketplace listing for that exact package and opens the pull request, with no Tessello server in between. A new Module awaits the Maintainer's Approval; a new version of a listed Module is merged automatically once the CI passes.
|
|
20
|
+
|
|
21
|
+
Before anything is opened it runs, on your machine, the checks the Marketplace CI will run: the package is validated, the URL must answer 200 with the same bytes as your `.zip` and send `Access-Control-Allow-Origin: *` (GitHub Pages does; GitHub Releases does not), the version must be new, and the Namespace must be yours. Every problem is printed with a `✗` and the command exits with 1.
|
|
22
|
+
|
|
23
|
+
| Option | |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `--url <URL>` | Where you published this exact `.zip` (required). |
|
|
26
|
+
| `--accept-terms` | You have read [PUBLISHING.md](https://github.com/craftskillz/tessello-marketplace/blob/main/PUBLISHING.md) and accept its terms. Required: the pull request ticks the box in your name. |
|
|
27
|
+
| `--source <repo URL>` | `https://github.com/<owner>/<repo>`, for a new Module. Default: the git remote `origin` of the current folder, when it is on GitHub. |
|
|
28
|
+
| `--license <SPDX>` | For a new Module. Default: the license GitHub reports for the source. |
|
|
29
|
+
| `--tag <tag>` | A tag for the listing; repeat it for several. They replace the previous tags. |
|
|
30
|
+
| `--marketplace <owner/name>` | Another Marketplace repository. Default: `craftskillz/tessello-marketplace`. |
|
|
31
|
+
| `--out <folder>` | Where the listing is written when no pull request is opened. Default: `./tessello-listing`. |
|
|
32
|
+
| `--dry-run` | Run every check and write the listing, but open nothing. |
|
|
33
|
+
|
|
34
|
+
With the [GitHub CLI](https://cli.github.com) (`gh`) installed and logged in, the pull request is opened for you: your fork of the Marketplace is created and synced if you cannot push to it, a branch `publish/<namespace>-<name>-<version>` carries the listing, and the pull request is opened from it. If an earlier run stopped half-way, running it again reuses that branch and updates the listing on it; if its pull request is already open, the command shows it. If a step fails, it says which one and keeps the listing in the `--out` folder so nothing is lost.
|
|
35
|
+
|
|
36
|
+
Without `gh`, or with `--dry-run`, the listing is written to `tessello-listing/modules/<namespace>/<name>/entry.json`. Without `gh` the command also lists the steps by hand: fork the Marketplace, add that file at the same path, open a pull request, tick the box.
|
|
37
|
+
|
|
38
|
+
It exits with 0 when done, 1 when a check or a step fails, and 2 on a usage error.
|
package/cli.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { execCommand, publishCommand } from "./publish-command.js";
|
|
4
5
|
import { formatReport, validatePath } from "./validate-command.js";
|
|
5
6
|
const USAGE = `Usage: tessello <command>
|
|
6
7
|
|
|
7
8
|
Commands:
|
|
8
9
|
validate <path> [--json] Check a Module folder or .zip against the Tessello Convention.
|
|
9
|
-
Exits with 1 if the Module is not installable
|
|
10
|
+
Exits with 1 if the Module is not installable.
|
|
11
|
+
publish <package.zip> --url <URL>
|
|
12
|
+
Prepare the Marketplace listing for a package you have published
|
|
13
|
+
at <URL> and open the pull request (run \`tessello publish\` for options).`;
|
|
10
14
|
/** Runs the CLI and returns the process exit code: 0 success, 1 invalid Module, 2 usage error. */
|
|
11
|
-
export async function run(args, output) {
|
|
15
|
+
export async function run(args, output, deps = {}) {
|
|
12
16
|
const [command, ...rest] = args;
|
|
17
|
+
if (command === "publish")
|
|
18
|
+
return publishCommand(rest, output, await publishDeps(deps));
|
|
13
19
|
if (command === "validate") {
|
|
14
20
|
const json = rest.includes("--json");
|
|
15
21
|
const paths = rest.filter((arg) => arg !== "--json");
|
|
@@ -36,6 +42,24 @@ export async function run(args, output) {
|
|
|
36
42
|
output.err(`Unknown command "${command}".\n\n${USAGE}`);
|
|
37
43
|
return 2;
|
|
38
44
|
}
|
|
45
|
+
/** The real gh (when installed and logged in), the GitHub token it holds, and the process's folder; `overrides` win. */
|
|
46
|
+
async function publishDeps(overrides) {
|
|
47
|
+
const realGh = (args) => execCommand("gh", args);
|
|
48
|
+
const gh = "gh" in overrides ? overrides.gh : (await realGh(["auth", "status"])).code === 0 ? realGh : null;
|
|
49
|
+
let token = process.env.GITHUB_TOKEN;
|
|
50
|
+
if (gh && !("gh" in overrides) && !overrides.github) {
|
|
51
|
+
const result = await gh(["auth", "token"]);
|
|
52
|
+
if (result.code === 0 && result.stdout.trim())
|
|
53
|
+
token = result.stdout.trim();
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
gh,
|
|
57
|
+
github: overrides.github ?? { apiUrl: "https://api.github.com", ...(token && { token }) },
|
|
58
|
+
cwd: overrides.cwd ?? process.cwd(),
|
|
59
|
+
...(overrides.git && { git: overrides.git }),
|
|
60
|
+
...(overrides.wait && { wait: overrides.wait }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
39
63
|
// npm runs the CLI through a symbolic link (node_modules/.bin/tessello), hence the real path.
|
|
40
64
|
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
41
65
|
process.exitCode = await run(process.argv.slice(2), {
|
package/listing.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readZip } from "@tessello/convention";
|
|
2
|
+
import { checkPullRequest, parseEntry, sha256 } from "@tessello/marketplace";
|
|
3
|
+
export const MARKETPLACE = "craftskillz/tessello-marketplace";
|
|
4
|
+
export const TERMS = "I have read PUBLISHING.md and accept its terms.";
|
|
5
|
+
const GITHUB_SOURCE = /^https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/;
|
|
6
|
+
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
7
|
+
/** Prepares the listing a pull request would carry, and checks it locally as the Marketplace CI will. Never throws for a Creator's mistake: returns the problems. */
|
|
8
|
+
export async function prepareListing(request) {
|
|
9
|
+
const fail = (...problems) => ({ ok: false, problems });
|
|
10
|
+
const doFetch = request.github.fetch ?? fetch;
|
|
11
|
+
const { report } = readZip(request.zip);
|
|
12
|
+
const errors = report.issues.filter((i) => i.severity === "error");
|
|
13
|
+
if (errors.length > 0 || !report.manifest)
|
|
14
|
+
return fail(...errors.map((i) => i.message));
|
|
15
|
+
const { id, version } = report.manifest;
|
|
16
|
+
const [namespace = "", name = ""] = id.slice(1).split("/");
|
|
17
|
+
const folder = `${namespace}/${name}`;
|
|
18
|
+
const path = `modules/${folder}/entry.json`;
|
|
19
|
+
const hash = await sha256(request.zip);
|
|
20
|
+
const hosted = await checkHosting(request.url, hash, doFetch);
|
|
21
|
+
if (hosted)
|
|
22
|
+
return fail(hosted);
|
|
23
|
+
try {
|
|
24
|
+
const api = githubReader(request.github, request.marketplace);
|
|
25
|
+
const previousBytes = await api.raw(path);
|
|
26
|
+
let entry;
|
|
27
|
+
let change;
|
|
28
|
+
const listed = { version, url: request.url, sha256: hash };
|
|
29
|
+
if (previousBytes === null) {
|
|
30
|
+
change = "new-module";
|
|
31
|
+
if (!request.source)
|
|
32
|
+
return fail("This Module is not listed yet: pass --source https://github.com/<owner>/<repo>, the public repository of its source.");
|
|
33
|
+
const match = GITHUB_SOURCE.exec(request.source);
|
|
34
|
+
if (!match)
|
|
35
|
+
return fail(`--source must be exactly https://github.com/<owner>/<repo>, got "${request.source}".`);
|
|
36
|
+
const license = request.license ?? (await api.licenseOf(match[1], match[2]));
|
|
37
|
+
if (!license)
|
|
38
|
+
return fail(`GitHub reports no license for ${request.source}: add one to the repository, or pass --license <SPDX identifier>.`);
|
|
39
|
+
entry = { id, source: request.source, license, ...(request.tags && { tags: request.tags }), versions: [listed] };
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
change = "new-version";
|
|
43
|
+
const parsed = parseEntry(JSON.parse(new TextDecoder().decode(previousBytes)));
|
|
44
|
+
if (!parsed.entry)
|
|
45
|
+
return fail(`The listing ${path} in ${request.marketplace} is not valid: ${parsed.issues.map((i) => i.message).join(" ")}`);
|
|
46
|
+
const previous = parsed.entry;
|
|
47
|
+
const existing = previous.versions.find((v) => v.version === version);
|
|
48
|
+
if (existing) {
|
|
49
|
+
return fail(existing.url === request.url && existing.sha256 === hash ? `Version ${version} of ${id} is already listed.` : `Version ${version} of ${id} is already listed with another package: a published version cannot change. Publish a new version instead.`);
|
|
50
|
+
}
|
|
51
|
+
if (request.source !== undefined && request.source !== previous.source)
|
|
52
|
+
return fail(`${id} is listed with the source ${previous.source}: only the Maintainer changes it, by hand.`);
|
|
53
|
+
if (request.license !== undefined && request.license !== previous.license)
|
|
54
|
+
return fail(`${id} is listed with the license ${previous.license}: only the Maintainer changes it, by hand.`);
|
|
55
|
+
entry = { ...previous, ...(request.tags && { tags: request.tags }), versions: [...previous.versions, listed] };
|
|
56
|
+
}
|
|
57
|
+
const json = `${JSON.stringify(entry, null, 2)}\n`;
|
|
58
|
+
const notes = [];
|
|
59
|
+
let author = request.author;
|
|
60
|
+
if (!author) {
|
|
61
|
+
const account = await api.account(namespace);
|
|
62
|
+
author = { login: account?.login ?? namespace, id: account?.id ?? 0 };
|
|
63
|
+
notes.push("Namespace ownership is checked by the CI: the GitHub account that opens the pull request is not known here.");
|
|
64
|
+
}
|
|
65
|
+
const check = await checkPullRequest({
|
|
66
|
+
author,
|
|
67
|
+
body: `- [x] ${TERMS}`,
|
|
68
|
+
source: pullRequestSource({ api, path, json, downloads: new Map([[request.url, request.zip]]), doFetch }),
|
|
69
|
+
});
|
|
70
|
+
const issues = request.author ? check.issues : check.issues.filter((i) => !i.code.startsWith("namespace/not-owner"));
|
|
71
|
+
if (issues.length > 0)
|
|
72
|
+
return fail(...issues.map((i) => i.message));
|
|
73
|
+
return { ok: true, listing: { folder, path, entry, json, change, decision: check.decision, version, notes } };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return fail(`Could not read GitHub: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The problem with the URL, or null when it serves exactly these bytes to any web page. */
|
|
80
|
+
async function checkHosting(url, hash, doFetch) {
|
|
81
|
+
let response;
|
|
82
|
+
let bytes;
|
|
83
|
+
try {
|
|
84
|
+
response = await doFetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
85
|
+
if (response.status !== 200) {
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
return `${url} answered ${response.status}: the package must be published there before it is listed.`;
|
|
88
|
+
}
|
|
89
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return `Could not download ${url}: ${error instanceof Error ? error.message : String(error)}`;
|
|
93
|
+
}
|
|
94
|
+
if ((await sha256(bytes)) !== hash)
|
|
95
|
+
return `${url} serves other bytes than this .zip: publish this exact file there.`;
|
|
96
|
+
if (response.headers.get("access-control-allow-origin") !== "*") {
|
|
97
|
+
return `${url} does not send the header "Access-Control-Allow-Origin: *", so the Host could not download it from a web page. GitHub Pages sends it; GitHub Releases does not.`;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function githubReader(access, marketplace) {
|
|
102
|
+
const doFetch = access.fetch ?? fetch;
|
|
103
|
+
const headers = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "tessello-cli", ...(access.token && { Authorization: `Bearer ${access.token}` }) };
|
|
104
|
+
/** The response, or null on 404. Any other failure throws. */
|
|
105
|
+
const get = async (url, accept) => {
|
|
106
|
+
const response = await doFetch(url, { headers: accept ? { ...headers, Accept: accept } : headers });
|
|
107
|
+
if (response.status === 404) {
|
|
108
|
+
await response.body?.cancel();
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok)
|
|
112
|
+
throw new Error(`GitHub answered ${response.status} to GET ${url}`);
|
|
113
|
+
return response;
|
|
114
|
+
};
|
|
115
|
+
const json = async (endpoint) => (await get(`${access.apiUrl}${endpoint}`))?.json() ?? null;
|
|
116
|
+
return {
|
|
117
|
+
repo: `/repos/${marketplace}`,
|
|
118
|
+
async raw(path) {
|
|
119
|
+
const response = await get(`${access.apiUrl}/repos/${marketplace}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=main`, "application/vnd.github.raw+json");
|
|
120
|
+
return response && new Uint8Array(await response.arrayBuffer());
|
|
121
|
+
},
|
|
122
|
+
json,
|
|
123
|
+
async account(login) {
|
|
124
|
+
const found = await json(`/users/${encodeURIComponent(login)}`);
|
|
125
|
+
return found && { login: found.login, id: found.id, type: found.type === "Organization" ? "Organization" : "User" };
|
|
126
|
+
},
|
|
127
|
+
async licenseOf(owner, name) {
|
|
128
|
+
const found = await json(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`);
|
|
129
|
+
const license = found?.license?.spdx_id;
|
|
130
|
+
return license && license !== "NOASSERTION" ? license : null;
|
|
131
|
+
},
|
|
132
|
+
async ok(endpoint) {
|
|
133
|
+
try {
|
|
134
|
+
const response = await doFetch(`${access.apiUrl}${endpoint}`, { headers });
|
|
135
|
+
await response.body?.cancel();
|
|
136
|
+
return response.ok;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** The Marketplace's main branch as the base, and the same with the new entry.json as the head. */
|
|
145
|
+
function pullRequestSource(input) {
|
|
146
|
+
const { api, path: listingPath, json, downloads } = input;
|
|
147
|
+
const bytes = new TextEncoder().encode(json);
|
|
148
|
+
const listingFolder = listingPath.slice(0, listingPath.lastIndexOf("/"));
|
|
149
|
+
const contents = (folder) => `${api.repo}/contents/${folder.split("/").map(encodeURIComponent).join("/")}?ref=main`;
|
|
150
|
+
return {
|
|
151
|
+
async changedFiles() {
|
|
152
|
+
return [{ path: listingPath }];
|
|
153
|
+
},
|
|
154
|
+
async file(side, path) {
|
|
155
|
+
return side === "head" && path === listingPath ? bytes : api.raw(path);
|
|
156
|
+
},
|
|
157
|
+
async folder(side, path) {
|
|
158
|
+
const found = await api.json(contents(path));
|
|
159
|
+
const files = new Map();
|
|
160
|
+
const others = [];
|
|
161
|
+
for (const item of Array.isArray(found) ? found : []) {
|
|
162
|
+
const content = item.type === "file" ? await api.raw(item.path) : null;
|
|
163
|
+
if (content)
|
|
164
|
+
files.set(item.name, content);
|
|
165
|
+
else
|
|
166
|
+
others.push(item.name);
|
|
167
|
+
}
|
|
168
|
+
if (side === "head" && path === listingFolder)
|
|
169
|
+
files.set("entry.json", bytes);
|
|
170
|
+
return { files, others };
|
|
171
|
+
},
|
|
172
|
+
account: (login) => api.account(login),
|
|
173
|
+
isPublicMember: (organization, login) => api.ok(`/orgs/${encodeURIComponent(organization)}/public_members/${encodeURIComponent(login)}`),
|
|
174
|
+
async isMaintainer(login) {
|
|
175
|
+
const found = await api.json(`${api.repo}/collaborators/${encodeURIComponent(login)}/permission`).catch(() => null);
|
|
176
|
+
return ["admin", "maintain", "write"].includes(found?.permission);
|
|
177
|
+
},
|
|
178
|
+
async repository(owner, name) {
|
|
179
|
+
const found = await api.json(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`);
|
|
180
|
+
if (!found)
|
|
181
|
+
return null;
|
|
182
|
+
const license = found.license?.spdx_id;
|
|
183
|
+
return { private: found.private, license: license && license !== "NOASSERTION" ? license : null };
|
|
184
|
+
},
|
|
185
|
+
async download(url) {
|
|
186
|
+
return downloads.get(url) ?? null;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tessello",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Tessello command-line tool.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@tessello/convention": "0.1.0"
|
|
8
|
+
"@tessello/convention": "0.1.0",
|
|
9
|
+
"@tessello/marketplace": "0.1.0"
|
|
9
10
|
},
|
|
10
11
|
"bin": {
|
|
11
12
|
"tessello": "./cli.js"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { MARKETPLACE, prepareListing, TERMS } from "./listing.js";
|
|
6
|
+
export const PUBLISH_USAGE = `Usage: tessello publish <package.zip> --url <URL> [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--url <URL> Where you published this exact .zip (required).
|
|
10
|
+
--source <repo URL> https://github.com/<owner>/<repo>, for a new Module (default: the git remote "origin").
|
|
11
|
+
--license <SPDX> For a new Module (default: the license GitHub reports for the source).
|
|
12
|
+
--tag <tag> A tag for the listing; repeat it for several. Replaces the previous tags.
|
|
13
|
+
--marketplace <owner/name> The Marketplace repository (default: ${MARKETPLACE}).
|
|
14
|
+
--accept-terms You have read PUBLISHING.md and accept its terms.
|
|
15
|
+
--out <folder> Where to write the listing when no pull request is opened (default: ./tessello-listing).
|
|
16
|
+
--dry-run Check everything and write the listing, but open no pull request.`;
|
|
17
|
+
/** What happens to the pull request, from the decision of the same check as the CI. */
|
|
18
|
+
const NEXT = {
|
|
19
|
+
approval: "a new Module, so the pull request awaits the Maintainer's Approval; it is listed once approved.",
|
|
20
|
+
merge: "the pull request is merged automatically once the CI passes.",
|
|
21
|
+
maintainer: "this change goes to the Maintainer, who merges it by hand.",
|
|
22
|
+
refuse: "the CI would refuse this pull request.",
|
|
23
|
+
};
|
|
24
|
+
/** Prepares or updates a listing and opens the pull request. Exit code: 0 done, 1 refused or failed, 2 usage error. */
|
|
25
|
+
export async function publishCommand(args, output, deps) {
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = parseArgs({
|
|
29
|
+
args,
|
|
30
|
+
allowPositionals: true,
|
|
31
|
+
options: {
|
|
32
|
+
url: { type: "string" },
|
|
33
|
+
source: { type: "string" },
|
|
34
|
+
license: { type: "string" },
|
|
35
|
+
tag: { type: "string", multiple: true },
|
|
36
|
+
marketplace: { type: "string" },
|
|
37
|
+
"accept-terms": { type: "boolean" },
|
|
38
|
+
out: { type: "string" },
|
|
39
|
+
"dry-run": { type: "boolean" },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
output.err(`${e.message}\n\n${PUBLISH_USAGE}`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
const { values, positionals } = parsed;
|
|
48
|
+
if (positionals.length !== 1 || !values.url) {
|
|
49
|
+
output.err(PUBLISH_USAGE);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const marketplace = values.marketplace ?? MARKETPLACE;
|
|
53
|
+
if (!values["accept-terms"]) {
|
|
54
|
+
output.err(`Publishing means accepting the terms: read https://github.com/${marketplace}/blob/main/PUBLISHING.md, then run this command again with --accept-terms (ticking the box in the pull request is your acceptance).`);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
let zip;
|
|
58
|
+
try {
|
|
59
|
+
zip = new Uint8Array(await readFile(resolve(deps.cwd, positionals[0])));
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
output.err(`Cannot read "${positionals[0]}": ${e.message}`);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
const author = await authorOf(deps.gh);
|
|
66
|
+
const prepared = await prepareListing({ zip, url: values.url, marketplace, author, github: deps.github, source: values.source ?? (await sourceFromGit(deps)), license: values.license, tags: values.tag });
|
|
67
|
+
if (!prepared.ok) {
|
|
68
|
+
for (const problem of prepared.problems)
|
|
69
|
+
output.err(`✗ ${problem}`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
const { listing } = prepared;
|
|
73
|
+
for (const note of listing.notes)
|
|
74
|
+
output.out(`Note: ${note}`);
|
|
75
|
+
output.out(`${listing.entry.id} ${listing.version}: ${NEXT[listing.decision]}`);
|
|
76
|
+
const outFolder = resolve(deps.cwd, values.out ?? "tessello-listing");
|
|
77
|
+
if (deps.gh && !values["dry-run"]) {
|
|
78
|
+
return openPullRequest({ gh: deps.gh, marketplace, listing, author, output, save: () => writeListing(outFolder, listing), wait: deps.wait ?? ((ms) => new Promise((done) => setTimeout(done, ms))) });
|
|
79
|
+
}
|
|
80
|
+
const written = await writeListing(outFolder, listing);
|
|
81
|
+
output.out(`Listing written to ${written}`);
|
|
82
|
+
if (!deps.gh) {
|
|
83
|
+
output.out([
|
|
84
|
+
"To publish it by hand:",
|
|
85
|
+
` 1. fork ${marketplace} on GitHub;`,
|
|
86
|
+
` 2. add that file at ${listing.path} in your fork;`,
|
|
87
|
+
` 3. open a pull request to ${marketplace};`,
|
|
88
|
+
" 4. tick the box saying you have read PUBLISHING.md and accept its terms.",
|
|
89
|
+
].join("\n"));
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
async function writeListing(folder, listing) {
|
|
94
|
+
const file = join(folder, listing.path);
|
|
95
|
+
await mkdir(dirname(file), { recursive: true });
|
|
96
|
+
await writeFile(file, listing.json);
|
|
97
|
+
return file;
|
|
98
|
+
}
|
|
99
|
+
/** The GitHub account gh is logged in with, or null without gh. */
|
|
100
|
+
async function authorOf(gh) {
|
|
101
|
+
if (!gh)
|
|
102
|
+
return null;
|
|
103
|
+
const result = await gh(["api", "user"]);
|
|
104
|
+
if (result.code !== 0)
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
const { login, id } = JSON.parse(result.stdout);
|
|
108
|
+
return typeof login === "string" && typeof id === "number" ? { login, id } : null;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** A new fork is tried this many times, this long apart: 30 s in all. */
|
|
115
|
+
const FORK_TRIES = 10;
|
|
116
|
+
const FORK_WAIT_MS = 3000;
|
|
117
|
+
/** Opens the pull request through gh, only with `gh api`, `gh repo` and `gh pr`. */
|
|
118
|
+
async function openPullRequest(opening) {
|
|
119
|
+
const { gh, marketplace, listing, author, output } = opening;
|
|
120
|
+
const stop = async (step, result) => {
|
|
121
|
+
output.err(`✗ ${step} failed: ${result.stderr.trim() || result.stdout.trim() || `gh exited with ${result.code}`}`);
|
|
122
|
+
output.err(`The listing was written to ${await opening.save()} so nothing is lost.`);
|
|
123
|
+
return 1;
|
|
124
|
+
};
|
|
125
|
+
if (!author)
|
|
126
|
+
return stop("Reading your GitHub account with `gh api user`", { code: 1, stdout: "", stderr: "run `gh auth login`, or use --dry-run" });
|
|
127
|
+
const login = author.login;
|
|
128
|
+
const repoName = marketplace.split("/")[1] ?? marketplace;
|
|
129
|
+
const pushed = await gh(["api", `repos/${marketplace}`, "--jq", ".permissions.push"]);
|
|
130
|
+
if (pushed.code !== 0)
|
|
131
|
+
return stop("Reading your permissions on the Marketplace", pushed);
|
|
132
|
+
const push = pushed.stdout.trim() === "true";
|
|
133
|
+
const target = push ? marketplace : `${login}/${repoName}`;
|
|
134
|
+
if (!push) {
|
|
135
|
+
const fork = await gh(["repo", "fork", marketplace, "--clone=false", "--default-branch-only"]);
|
|
136
|
+
if (fork.code !== 0)
|
|
137
|
+
return stop(`Forking ${marketplace}`, fork);
|
|
138
|
+
// GitHub creates a new fork in the background: it is synced again until it is ready.
|
|
139
|
+
let sync = await gh(["repo", "sync", target, "--source", marketplace, "--branch", "main"]);
|
|
140
|
+
for (let tries = 1; sync.code !== 0 && tries < FORK_TRIES; tries++) {
|
|
141
|
+
await opening.wait(FORK_WAIT_MS);
|
|
142
|
+
sync = await gh(["repo", "sync", target, "--source", marketplace, "--branch", "main"]);
|
|
143
|
+
}
|
|
144
|
+
if (sync.code !== 0)
|
|
145
|
+
return stop(`Syncing ${target} with ${marketplace}`, sync);
|
|
146
|
+
}
|
|
147
|
+
const base = await gh(["api", `repos/${target}/git/ref/heads/main`, "--jq", ".object.sha"]);
|
|
148
|
+
if (base.code !== 0)
|
|
149
|
+
return stop(`Reading the main branch of ${target}`, base);
|
|
150
|
+
const branch = `publish/${listing.folder.replace("/", "-")}-${listing.version}`;
|
|
151
|
+
const created = await gh(["api", "-X", "POST", `repos/${target}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${base.stdout.trim()}`]);
|
|
152
|
+
// The branch of an earlier run that stopped half-way is reused: the listing on it is updated.
|
|
153
|
+
if (created.code !== 0 && !/already exists/i.test(created.stderr))
|
|
154
|
+
return stop(`Creating the branch ${branch} in ${target}`, created);
|
|
155
|
+
const existing = await gh(["api", `repos/${target}/contents/${listing.path}?ref=${branch}`, "--jq", ".sha"]);
|
|
156
|
+
const fileSha = existing.code === 0 ? existing.stdout.trim() : "";
|
|
157
|
+
const title = `${listing.entry.id} ${listing.version}`;
|
|
158
|
+
const put = await gh([
|
|
159
|
+
"api", "-X", "PUT", `repos/${target}/contents/${listing.path}`,
|
|
160
|
+
"-f", `message=${title}`,
|
|
161
|
+
"-f", `content=${Buffer.from(listing.json).toString("base64")}`,
|
|
162
|
+
"-f", `branch=${branch}`,
|
|
163
|
+
...(fileSha ? ["-f", `sha=${fileSha}`] : []),
|
|
164
|
+
]);
|
|
165
|
+
if (put.code !== 0)
|
|
166
|
+
return stop(`Adding ${listing.path} to ${target}`, put);
|
|
167
|
+
const what = listing.change === "new-module" ? "a new Module" : `a new version (${listing.version})`;
|
|
168
|
+
const body = [
|
|
169
|
+
"<!-- One pull request changes one listing folder, modules/<namespace>/<name>/, and nothing else. -->",
|
|
170
|
+
"",
|
|
171
|
+
`**Module:** \`${listing.entry.id}\``,
|
|
172
|
+
"",
|
|
173
|
+
`**What this pull request does:** ${what}`,
|
|
174
|
+
"",
|
|
175
|
+
`- [x] ${TERMS}`,
|
|
176
|
+
"",
|
|
177
|
+
"Opened with `tessello publish`.",
|
|
178
|
+
].join("\n");
|
|
179
|
+
const pr = await gh(["pr", "create", "--repo", marketplace, "--base", "main", "--head", push ? branch : `${login}:${branch}`, "--title", title, "--body", body]);
|
|
180
|
+
const open = /already exists:\s*(\S+)/.exec(pr.stderr);
|
|
181
|
+
if (pr.code !== 0 && open)
|
|
182
|
+
output.out(`A pull request for this version is already open: ${open[1]}`);
|
|
183
|
+
else if (pr.code !== 0)
|
|
184
|
+
return stop("Opening the pull request", pr);
|
|
185
|
+
else
|
|
186
|
+
output.out(pr.stdout.trim());
|
|
187
|
+
output.out(`Next: ${NEXT[listing.decision]}`);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
/** The GitHub repository of the git remote "origin" in cwd, as https://github.com/<owner>/<repo>, or undefined. */
|
|
191
|
+
async function sourceFromGit(deps) {
|
|
192
|
+
const git = deps.git ?? ((args) => execCommand("git", args, deps.cwd));
|
|
193
|
+
const result = await git(["remote", "get-url", "origin"]).catch(() => null);
|
|
194
|
+
if (!result || result.code !== 0)
|
|
195
|
+
return undefined;
|
|
196
|
+
const match = /^(?:git@github\.com:|https:\/\/github\.com\/)([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(result.stdout.trim());
|
|
197
|
+
return match ? `https://github.com/${match[1]}/${match[2]}` : undefined;
|
|
198
|
+
}
|
|
199
|
+
/** Runs a program with these arguments, never through a shell. Resolves with its exit code and output. */
|
|
200
|
+
export function execCommand(file, args, cwd) {
|
|
201
|
+
return new Promise((done) => {
|
|
202
|
+
execFile(file, args, { cwd }, (error, stdout, stderr) => {
|
|
203
|
+
done({ code: error ? (typeof error.code === "number" ? error.code : 1) : 0, stdout, stderr });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|