@moonbase.sh/cli 0.0.0-next-20261001173449
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 +119 -0
- package/dist/cli.js +448 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @moonbase.sh/cli
|
|
2
|
+
|
|
3
|
+
Publish product releases to Moonbase from the command line or a CI job, in one command.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
export MOONBASE_ENDPOINT=https://acme.moonbase.sh
|
|
7
|
+
export MOONBASE_API_KEY=mb_...
|
|
8
|
+
|
|
9
|
+
npx @moonbase.sh/cli release moon-terminal v1.4.0 "dist/Moon Terminal.dmg" "dist/Moon Terminal Setup.exe" --notes-file NOTES.md --publish
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
That uploads both files, creates release 1.4.0 of the product `moon-terminal` from them, and
|
|
13
|
+
publishes it. It needs Node.js 20 or later.
|
|
14
|
+
|
|
15
|
+
`MOONBASE_ENDPOINT` is your account's address and `MOONBASE_API_KEY` an API key. Both are under
|
|
16
|
+
Account settings > Developer in Moonbase. Every command also takes them as `--endpoint` and
|
|
17
|
+
`--api-key`, which win over the variables. Keep the key secret: in CI, put it in a secret rather
|
|
18
|
+
than in the workflow file. Prefer the variable to `--api-key` on a machine you share, since other
|
|
19
|
+
users can see a command's options while it runs, and your shell history keeps them.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
### `release <product-id> <version> <file...>`
|
|
24
|
+
|
|
25
|
+
Uploads the files and creates the release from them. Before it uploads anything, it checks that the
|
|
26
|
+
product exists, that the release does not (unless you pass `--add`) and that every file is there.
|
|
27
|
+
|
|
28
|
+
- The version is `MAJOR.MINOR.PATCH`. A leading `v` is dropped, so a Git tag like `v1.4.0` works as
|
|
29
|
+
it is.
|
|
30
|
+
- Each download is named after its file.
|
|
31
|
+
- A file's platform is guessed from its extension (`.dmg` and `.pkg` are Mac, `.exe` and `.msi`
|
|
32
|
+
Windows, `.deb`, `.rpm` and `.AppImage` Linux), then from a word in its name (`mac`, `win`,
|
|
33
|
+
`linux`), and is Universal otherwise. Its architecture is guessed from a word in its name (`arm64`,
|
|
34
|
+
`x64`, `x86_64`, `universal` and the like) and is left out otherwise. The command lists what it
|
|
35
|
+
guessed before it uploads. `--platform` and `--arch` set them for every file instead.
|
|
36
|
+
- A file larger than 64 MiB is sent in parts, four at a time, and a part that fails is retried.
|
|
37
|
+
|
|
38
|
+
| Option | |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `--notes <text>` | The release notes |
|
|
41
|
+
| `--notes-file <path>` | Read the release notes from a file |
|
|
42
|
+
| `--platform <name>` | `Universal`, `Windows`, `Linux`, `Mac`, `iOS` or `Android` |
|
|
43
|
+
| `--arch <name>` | `Universal`, `X86`, `X64`, `Arm` or `Arm64` |
|
|
44
|
+
| `--publish` | Publish the release, so customers can download it. Without it the release stays unpublished. |
|
|
45
|
+
| `--add` | If the release exists, add the files to it. A file with the same name, platform and architecture as one already there replaces it. |
|
|
46
|
+
| `--json` | Print the release as JSON |
|
|
47
|
+
| `--endpoint <url>` | Your account's address, instead of `MOONBASE_ENDPOINT` |
|
|
48
|
+
| `--api-key <key>` | An API key, instead of `MOONBASE_API_KEY` |
|
|
49
|
+
|
|
50
|
+
### `publish <product-id> <version>`
|
|
51
|
+
|
|
52
|
+
Publishes a release, so customers can download it. Takes `--json`, `--endpoint` and `--api-key`.
|
|
53
|
+
|
|
54
|
+
### `upload <file>`
|
|
55
|
+
|
|
56
|
+
Uploads one file and prints its key, for adding it to a release through the API or
|
|
57
|
+
[`@moonbase.sh/api`](https://www.npmjs.com/package/@moonbase.sh/api). Takes `--endpoint` and
|
|
58
|
+
`--api-key`.
|
|
59
|
+
|
|
60
|
+
Progress and messages go to stderr, so stdout holds only the key or, with `--json`, the release.
|
|
61
|
+
The exit code is 0 when the command worked, 1 when it failed and 2 when it was run the wrong way.
|
|
62
|
+
|
|
63
|
+
## GitHub Actions
|
|
64
|
+
|
|
65
|
+
Release when a version tag is pushed:
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
on:
|
|
69
|
+
push:
|
|
70
|
+
tags: ['v*']
|
|
71
|
+
|
|
72
|
+
jobs:
|
|
73
|
+
release:
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/checkout@v4
|
|
77
|
+
- run: ./build.sh # puts the installers in dist/
|
|
78
|
+
- run: npx @moonbase.sh/cli release moon-terminal ${{ github.ref_name }} dist/* --publish
|
|
79
|
+
env:
|
|
80
|
+
MOONBASE_ENDPOINT: https://acme.moonbase.sh
|
|
81
|
+
MOONBASE_API_KEY: ${{ secrets.MOONBASE_API_KEY }}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
When each platform builds on its own runner, let each job add its files with `--add`, and publish
|
|
85
|
+
once they all have:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
jobs:
|
|
89
|
+
build:
|
|
90
|
+
strategy:
|
|
91
|
+
matrix:
|
|
92
|
+
os: [macos-latest, windows-latest, ubuntu-latest]
|
|
93
|
+
runs-on: ${{ matrix.os }}
|
|
94
|
+
steps:
|
|
95
|
+
- uses: actions/checkout@v4
|
|
96
|
+
- run: ./build.sh
|
|
97
|
+
shell: bash
|
|
98
|
+
# bash, so `dist/*` is expanded on Windows too.
|
|
99
|
+
- run: npx @moonbase.sh/cli release moon-terminal ${{ github.ref_name }} dist/* --add
|
|
100
|
+
shell: bash
|
|
101
|
+
env:
|
|
102
|
+
MOONBASE_ENDPOINT: https://acme.moonbase.sh
|
|
103
|
+
MOONBASE_API_KEY: ${{ secrets.MOONBASE_API_KEY }}
|
|
104
|
+
|
|
105
|
+
publish:
|
|
106
|
+
needs: build
|
|
107
|
+
runs-on: ubuntu-latest
|
|
108
|
+
steps:
|
|
109
|
+
- run: npx @moonbase.sh/cli publish moon-terminal ${{ github.ref_name }}
|
|
110
|
+
env:
|
|
111
|
+
MOONBASE_ENDPOINT: https://acme.moonbase.sh
|
|
112
|
+
MOONBASE_API_KEY: ${{ secrets.MOONBASE_API_KEY }}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Jobs that finish at the same moment are fine: when Moonbase turns one job's change away because
|
|
116
|
+
another's landed first, the command sends it again. A rerun of a job replaces the files it added
|
|
117
|
+
before, since they have the same names.
|
|
118
|
+
|
|
119
|
+
To keep a workflow on a version you have tried, pin it: `npx @moonbase.sh/cli@0.1 release ...`.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import process2 from "process";
|
|
5
|
+
|
|
6
|
+
// src/main.ts
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import process from "process";
|
|
9
|
+
import { parseArgs } from "util";
|
|
10
|
+
import { ConflictError, MoonbaseClient, MoonbaseError, NotAuthenticatedError, NotFoundError } from "@moonbase.sh/api";
|
|
11
|
+
|
|
12
|
+
// package.json
|
|
13
|
+
var version = "0.0.0-next-20261001173449";
|
|
14
|
+
|
|
15
|
+
// src/files.ts
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { Architecture, Platform } from "@moonbase.sh/api";
|
|
18
|
+
var platformByExtension = {
|
|
19
|
+
exe: Platform.Windows,
|
|
20
|
+
msi: Platform.Windows,
|
|
21
|
+
msix: Platform.Windows,
|
|
22
|
+
appx: Platform.Windows,
|
|
23
|
+
dmg: Platform.Mac,
|
|
24
|
+
pkg: Platform.Mac,
|
|
25
|
+
deb: Platform.Linux,
|
|
26
|
+
rpm: Platform.Linux,
|
|
27
|
+
appimage: Platform.Linux,
|
|
28
|
+
snap: Platform.Linux,
|
|
29
|
+
flatpak: Platform.Linux
|
|
30
|
+
};
|
|
31
|
+
var platformByWord = [
|
|
32
|
+
[["mac", "macos", "osx", "darwin"], Platform.Mac],
|
|
33
|
+
[["win", "windows", "win32", "win64"], Platform.Windows],
|
|
34
|
+
[["linux"], Platform.Linux]
|
|
35
|
+
];
|
|
36
|
+
var archByWord = [
|
|
37
|
+
[["arm64", "aarch64"], Architecture.Arm64],
|
|
38
|
+
[["x86_64", "x86-64", "x64", "amd64"], Architecture.X64],
|
|
39
|
+
[["x86", "i386", "i686", "ia32"], Architecture.X86],
|
|
40
|
+
[["armv7", "armv7l", "armhf"], Architecture.Arm],
|
|
41
|
+
[["universal"], Architecture.Universal]
|
|
42
|
+
];
|
|
43
|
+
var contentTypeByExtension = {
|
|
44
|
+
"7z": "application/x-7z-compressed",
|
|
45
|
+
"appimage": "application/vnd.appimage",
|
|
46
|
+
"deb": "application/vnd.debian.binary-package",
|
|
47
|
+
"dmg": "application/x-apple-diskimage",
|
|
48
|
+
"exe": "application/vnd.microsoft.portable-executable",
|
|
49
|
+
"gz": "application/gzip",
|
|
50
|
+
"msi": "application/x-msi",
|
|
51
|
+
"pdf": "application/pdf",
|
|
52
|
+
"rpm": "application/x-rpm",
|
|
53
|
+
"tar": "application/x-tar",
|
|
54
|
+
"tgz": "application/gzip",
|
|
55
|
+
"zip": "application/zip"
|
|
56
|
+
};
|
|
57
|
+
function describeFile(filePath, size, overrides = {}) {
|
|
58
|
+
const name = path.basename(filePath);
|
|
59
|
+
const extension = path.extname(name).slice(1).toLowerCase();
|
|
60
|
+
const words = name.slice(0, name.length - path.extname(name).length).toLowerCase();
|
|
61
|
+
return {
|
|
62
|
+
path: filePath,
|
|
63
|
+
name,
|
|
64
|
+
size,
|
|
65
|
+
platform: overrides.platform ?? platformByExtension[extension] ?? findWord(words, platformByWord) ?? Platform.Universal,
|
|
66
|
+
arch: overrides.arch ?? findWord(words, archByWord),
|
|
67
|
+
contentType: contentTypeByExtension[extension] ?? "application/octet-stream"
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function findWord(text, table) {
|
|
71
|
+
return table.find(([words]) => words.some((word) => new RegExp(`(^|[^a-z0-9])${word}($|[^a-z0-9])`).test(text)))?.[1];
|
|
72
|
+
}
|
|
73
|
+
function parseVersion(input) {
|
|
74
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/i.exec(input.trim());
|
|
75
|
+
return match ? match.slice(1).map(Number).join(".") : void 0;
|
|
76
|
+
}
|
|
77
|
+
function parsePlatform(input) {
|
|
78
|
+
return Object.values(Platform).find((platform) => platform.toLowerCase() === input.toLowerCase());
|
|
79
|
+
}
|
|
80
|
+
function parseArch(input) {
|
|
81
|
+
return Object.values(Architecture).find((arch) => arch !== Architecture.Unknown && arch.toLowerCase() === input.toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
function formatBytes(bytes) {
|
|
84
|
+
const units = ["bytes", "KB", "MB", "GB", "TB"];
|
|
85
|
+
let value = bytes;
|
|
86
|
+
let unit = 0;
|
|
87
|
+
while (value >= 1e3 && unit < units.length - 1) {
|
|
88
|
+
value /= 1e3;
|
|
89
|
+
unit++;
|
|
90
|
+
}
|
|
91
|
+
return unit === 0 ? `${bytes} bytes` : `${value.toFixed(1)} ${units[unit]}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/main.ts
|
|
95
|
+
var UsageError = class extends Error {
|
|
96
|
+
constructor(message, command) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.command = command;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
var silentLogger = { debug: () => {
|
|
102
|
+
}, log: () => {
|
|
103
|
+
}, warn: () => {
|
|
104
|
+
}, error: () => {
|
|
105
|
+
} };
|
|
106
|
+
var raceAttempts = 6;
|
|
107
|
+
var help = {
|
|
108
|
+
main: `Usage: moonbase <command> [options]
|
|
109
|
+
|
|
110
|
+
Publish product releases to Moonbase from the command line or a CI job.
|
|
111
|
+
|
|
112
|
+
Commands:
|
|
113
|
+
release <product-id> <version> <file...> Upload files and create a release from them
|
|
114
|
+
upload <file> Upload one file and print its key
|
|
115
|
+
publish <product-id> <version> Publish a release
|
|
116
|
+
|
|
117
|
+
Environment:
|
|
118
|
+
MOONBASE_ENDPOINT Your account's address, like https://acme.moonbase.sh
|
|
119
|
+
MOONBASE_API_KEY An API key
|
|
120
|
+
Both are under Account settings > Developer in Moonbase. Every command also takes them as
|
|
121
|
+
--endpoint and --api-key.
|
|
122
|
+
|
|
123
|
+
Run "moonbase help <command>" for a command's options.
|
|
124
|
+
`,
|
|
125
|
+
release: `Usage: moonbase release <product-id> <version> <file...> [options]
|
|
126
|
+
|
|
127
|
+
Uploads the files and creates the release from them. The version is MAJOR.MINOR.PATCH, and a
|
|
128
|
+
leading "v" is dropped, so a Git tag like v1.4.0 works as it is. The release stays unpublished
|
|
129
|
+
unless you pass --publish.
|
|
130
|
+
|
|
131
|
+
A file's platform is guessed from its extension (.dmg and .pkg are Mac, .exe and .msi Windows,
|
|
132
|
+
.deb, .rpm and .AppImage Linux), then from a word in its name (mac, win, linux), and is Universal
|
|
133
|
+
otherwise. Its architecture is guessed from a word in its name (arm64, x64, x86_64, universal) and
|
|
134
|
+
is left out otherwise. --platform and --arch set them for every file instead.
|
|
135
|
+
|
|
136
|
+
Options:
|
|
137
|
+
--notes <text> The release notes
|
|
138
|
+
--notes-file <path> Read the release notes from a file
|
|
139
|
+
--platform <name> Universal, Windows, Linux, Mac, iOS or Android
|
|
140
|
+
--arch <name> Universal, X86, X64, Arm or Arm64
|
|
141
|
+
--publish Publish the release, so customers can download it
|
|
142
|
+
--add If the release exists, add the files to it. A file with the same name,
|
|
143
|
+
platform and architecture as one already there replaces it.
|
|
144
|
+
--json Print the release as JSON
|
|
145
|
+
--endpoint <url> Your account's address, instead of MOONBASE_ENDPOINT
|
|
146
|
+
--api-key <key> An API key, instead of MOONBASE_API_KEY. Other users of the machine
|
|
147
|
+
can see it while the command runs, so prefer the variable.
|
|
148
|
+
`,
|
|
149
|
+
upload: `Usage: moonbase upload <file> [options]
|
|
150
|
+
|
|
151
|
+
Uploads a file and prints its key, for adding it to a release with the API.
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
--endpoint <url> Your account's address, instead of MOONBASE_ENDPOINT
|
|
155
|
+
--api-key <key> An API key, instead of MOONBASE_API_KEY. Other users of the machine
|
|
156
|
+
can see it while the command runs, so prefer the variable.
|
|
157
|
+
`,
|
|
158
|
+
publish: `Usage: moonbase publish <product-id> <version> [options]
|
|
159
|
+
|
|
160
|
+
Publishes a release, so customers can download it.
|
|
161
|
+
|
|
162
|
+
Options:
|
|
163
|
+
--json Print the release as JSON
|
|
164
|
+
--endpoint <url> Your account's address, instead of MOONBASE_ENDPOINT
|
|
165
|
+
--api-key <key> An API key, instead of MOONBASE_API_KEY. Other users of the machine
|
|
166
|
+
can see it while the command runs, so prefer the variable.
|
|
167
|
+
`
|
|
168
|
+
};
|
|
169
|
+
async function main(argv, io) {
|
|
170
|
+
const [command, ...args] = argv;
|
|
171
|
+
try {
|
|
172
|
+
switch (command) {
|
|
173
|
+
case "release":
|
|
174
|
+
return await release(args, io);
|
|
175
|
+
case "upload":
|
|
176
|
+
return await upload(args, io);
|
|
177
|
+
case "publish":
|
|
178
|
+
return await publish(args, io);
|
|
179
|
+
case "help":
|
|
180
|
+
case "--help":
|
|
181
|
+
case "-h":
|
|
182
|
+
io.stdout.write(help[args[0]] ?? help.main);
|
|
183
|
+
return 0;
|
|
184
|
+
case "--version":
|
|
185
|
+
io.stdout.write(`${version}
|
|
186
|
+
`);
|
|
187
|
+
return 0;
|
|
188
|
+
case void 0:
|
|
189
|
+
io.stderr.write(help.main);
|
|
190
|
+
return 2;
|
|
191
|
+
default:
|
|
192
|
+
throw new UsageError(`There is no command "${command}".`, "main");
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof UsageError) {
|
|
196
|
+
io.stderr.write(`${error.message}
|
|
197
|
+
`);
|
|
198
|
+
if (error.command)
|
|
199
|
+
io.stderr.write(`Run "moonbase help${error.command === "main" ? "" : ` ${error.command}`}" for how to use it.
|
|
200
|
+
`);
|
|
201
|
+
return 2;
|
|
202
|
+
}
|
|
203
|
+
io.stderr.write(`Error: ${describeError(error)}
|
|
204
|
+
`);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function release(args, io) {
|
|
209
|
+
const { values, positionals } = parse("release", () => parseArgs({
|
|
210
|
+
args,
|
|
211
|
+
allowPositionals: true,
|
|
212
|
+
options: {
|
|
213
|
+
"notes": { type: "string" },
|
|
214
|
+
"notes-file": { type: "string" },
|
|
215
|
+
"platform": { type: "string" },
|
|
216
|
+
"arch": { type: "string" },
|
|
217
|
+
"publish": { type: "boolean", default: false },
|
|
218
|
+
"add": { type: "boolean", default: false },
|
|
219
|
+
"json": { type: "boolean", default: false },
|
|
220
|
+
"endpoint": { type: "string" },
|
|
221
|
+
"api-key": { type: "string" },
|
|
222
|
+
"help": { type: "boolean", short: "h", default: false }
|
|
223
|
+
}
|
|
224
|
+
}));
|
|
225
|
+
if (values.help) {
|
|
226
|
+
io.stdout.write(help.release);
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
const [productId, versionArgument, ...paths] = positionals;
|
|
230
|
+
if (!productId || !versionArgument || paths.length === 0)
|
|
231
|
+
throw new UsageError("release takes a product ID, a version and at least one file.", "release");
|
|
232
|
+
const version2 = readVersion(versionArgument, "release");
|
|
233
|
+
if (values.notes !== void 0 && values["notes-file"] !== void 0)
|
|
234
|
+
throw new UsageError("Pass --notes or --notes-file, not both.", "release");
|
|
235
|
+
const platform = values.platform === void 0 ? void 0 : parsePlatform(values.platform);
|
|
236
|
+
if (values.platform !== void 0 && !platform)
|
|
237
|
+
throw new UsageError(`There is no platform "${values.platform}".`, "release");
|
|
238
|
+
const arch = values.arch === void 0 ? void 0 : parseArch(values.arch);
|
|
239
|
+
if (values.arch !== void 0 && !arch)
|
|
240
|
+
throw new UsageError(`There is no architecture "${values.arch}".`, "release");
|
|
241
|
+
const description = values["notes-file"] !== void 0 ? await fs.promises.readFile(values["notes-file"], "utf8") : values.notes;
|
|
242
|
+
const files = await inspectFiles(paths, { platform, arch });
|
|
243
|
+
const client = connect(values.endpoint, values["api-key"], io);
|
|
244
|
+
try {
|
|
245
|
+
await client.products.get(productId);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof NotFoundError)
|
|
248
|
+
throw new Error(`There is no product with the ID "${productId}".`);
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
let existing = await findRelease(client, productId, version2);
|
|
252
|
+
if (existing && !values.add)
|
|
253
|
+
throw new Error(`Release ${version2} of ${productId} already exists. Pass --add to add these files to it.`);
|
|
254
|
+
io.stderr.write(`${existing ? "Adding to" : "Creating"} release ${version2} of ${productId}:
|
|
255
|
+
`);
|
|
256
|
+
const nameWidth = Math.max(...files.map((file) => file.name.length));
|
|
257
|
+
for (const file of files)
|
|
258
|
+
io.stderr.write(` ${file.name.padEnd(nameWidth)} ${[file.platform, file.arch].filter(Boolean).join(", ")}, ${formatBytes(file.size)}
|
|
259
|
+
`);
|
|
260
|
+
const downloads = [];
|
|
261
|
+
for (const file of files)
|
|
262
|
+
downloads.push({ name: file.name, platform: file.platform, arch: file.arch, key: await uploadFile(client, file, io) });
|
|
263
|
+
let result;
|
|
264
|
+
if (!existing) {
|
|
265
|
+
try {
|
|
266
|
+
result = await retryRace(io, () => client.products.createRelease(productId, { version: version2, description, downloads }, { publishImmediately: values.publish }));
|
|
267
|
+
io.stderr.write(`Created release ${version2} of ${productId}${values.publish ? " and published it" : ""}.
|
|
268
|
+
`);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (!(error instanceof ConflictError && values.add))
|
|
271
|
+
throw error;
|
|
272
|
+
existing = await findRelease(client, productId, version2);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!result) {
|
|
276
|
+
if (description !== void 0)
|
|
277
|
+
io.stderr.write(`Release ${version2} already existed, so its notes were left as they were.
|
|
278
|
+
`);
|
|
279
|
+
for (const download of downloads)
|
|
280
|
+
result = await retryRace(io, () => client.products.addReleaseDownload(productId, version2, download));
|
|
281
|
+
io.stderr.write(`Added ${downloads.length === 1 ? "the file" : `${downloads.length} files`} to release ${version2} of ${productId}.
|
|
282
|
+
`);
|
|
283
|
+
if (existing?.publishedAt)
|
|
284
|
+
io.stderr.write(`The release was already published, so customers can download them now.
|
|
285
|
+
`);
|
|
286
|
+
if (values.publish && !existing?.publishedAt) {
|
|
287
|
+
result = await retryRace(io, () => client.products.publishRelease(productId, version2));
|
|
288
|
+
io.stderr.write(`Published it.
|
|
289
|
+
`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!result.publishedAt)
|
|
293
|
+
io.stderr.write(`It is not published yet. Publish it with: moonbase publish ${productId} ${version2}
|
|
294
|
+
`);
|
|
295
|
+
if (values.json)
|
|
296
|
+
io.stdout.write(`${JSON.stringify(result, void 0, 2)}
|
|
297
|
+
`);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
async function upload(args, io) {
|
|
301
|
+
const { values, positionals } = parse("upload", () => parseArgs({
|
|
302
|
+
args,
|
|
303
|
+
allowPositionals: true,
|
|
304
|
+
options: {
|
|
305
|
+
"endpoint": { type: "string" },
|
|
306
|
+
"api-key": { type: "string" },
|
|
307
|
+
"help": { type: "boolean", short: "h", default: false }
|
|
308
|
+
}
|
|
309
|
+
}));
|
|
310
|
+
if (values.help) {
|
|
311
|
+
io.stdout.write(help.upload);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
if (positionals.length !== 1)
|
|
315
|
+
throw new UsageError("upload takes one file.", "upload");
|
|
316
|
+
const [file] = await inspectFiles(positionals);
|
|
317
|
+
const client = connect(values.endpoint, values["api-key"], io);
|
|
318
|
+
io.stdout.write(`${await uploadFile(client, file, io)}
|
|
319
|
+
`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
async function publish(args, io) {
|
|
323
|
+
const { values, positionals } = parse("publish", () => parseArgs({
|
|
324
|
+
args,
|
|
325
|
+
allowPositionals: true,
|
|
326
|
+
options: {
|
|
327
|
+
"json": { type: "boolean", default: false },
|
|
328
|
+
"endpoint": { type: "string" },
|
|
329
|
+
"api-key": { type: "string" },
|
|
330
|
+
"help": { type: "boolean", short: "h", default: false }
|
|
331
|
+
}
|
|
332
|
+
}));
|
|
333
|
+
if (values.help) {
|
|
334
|
+
io.stdout.write(help.publish);
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
const [productId, versionArgument, ...rest] = positionals;
|
|
338
|
+
if (!productId || !versionArgument || rest.length > 0)
|
|
339
|
+
throw new UsageError("publish takes a product ID and a version.", "publish");
|
|
340
|
+
const version2 = readVersion(versionArgument, "publish");
|
|
341
|
+
const client = connect(values.endpoint, values["api-key"], io);
|
|
342
|
+
let result;
|
|
343
|
+
try {
|
|
344
|
+
result = await retryRace(io, () => client.products.publishRelease(productId, version2));
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error instanceof NotFoundError)
|
|
347
|
+
throw new Error(`There is no release ${version2} of a product with the ID "${productId}".`);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
io.stderr.write(`Published release ${version2} of ${productId}.
|
|
351
|
+
`);
|
|
352
|
+
if (values.json)
|
|
353
|
+
io.stdout.write(`${JSON.stringify(result, void 0, 2)}
|
|
354
|
+
`);
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
function parse(command, read) {
|
|
358
|
+
try {
|
|
359
|
+
return read();
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw new UsageError(error instanceof Error ? error.message : String(error), command);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function readVersion(argument, command) {
|
|
365
|
+
const version2 = parseVersion(argument);
|
|
366
|
+
if (!version2)
|
|
367
|
+
throw new UsageError(`"${argument}" is not a version. Use MAJOR.MINOR.PATCH, like 1.4.0.`, command);
|
|
368
|
+
return version2;
|
|
369
|
+
}
|
|
370
|
+
function connect(endpointOption, apiKeyOption, io) {
|
|
371
|
+
let endpoint = endpointOption ?? io.env.MOONBASE_ENDPOINT;
|
|
372
|
+
const apiKey = apiKeyOption ?? io.env.MOONBASE_API_KEY;
|
|
373
|
+
if (!endpoint)
|
|
374
|
+
throw new UsageError("Set MOONBASE_ENDPOINT, or pass --endpoint, to your account's address, like https://acme.moonbase.sh. It is under Account settings > Developer in Moonbase.");
|
|
375
|
+
if (!apiKey)
|
|
376
|
+
throw new UsageError("Set MOONBASE_API_KEY, or pass --api-key, to an API key from Account settings > Developer in Moonbase.");
|
|
377
|
+
if (!/^https?:\/\//i.test(endpoint))
|
|
378
|
+
endpoint = `https://${endpoint}`;
|
|
379
|
+
return io.createClient?.(endpoint, apiKey) ?? new MoonbaseClient({ endpoint, apiKey, logger: silentLogger });
|
|
380
|
+
}
|
|
381
|
+
async function inspectFiles(paths, overrides) {
|
|
382
|
+
if (typeof fs.openAsBlob !== "function")
|
|
383
|
+
throw new Error(`Uploading needs Node.js 20 or later, and this is ${process.version}.`);
|
|
384
|
+
const files = [];
|
|
385
|
+
for (const filePath of paths) {
|
|
386
|
+
const stats = await fs.promises.stat(filePath).catch(() => void 0);
|
|
387
|
+
if (!stats)
|
|
388
|
+
throw new Error(`There is no file at ${filePath}.`);
|
|
389
|
+
if (!stats.isFile())
|
|
390
|
+
throw new Error(`${filePath} is not a file. Archive a folder, like a .app bundle, before uploading it.`);
|
|
391
|
+
files.push(describeFile(filePath, stats.size, overrides));
|
|
392
|
+
}
|
|
393
|
+
const seen = /* @__PURE__ */ new Set();
|
|
394
|
+
for (const file of files) {
|
|
395
|
+
const identity = [file.name, file.platform, file.arch].join("/");
|
|
396
|
+
if (seen.has(identity))
|
|
397
|
+
throw new Error(`Two of the files are ${file.name} for ${[file.platform, file.arch].filter(Boolean).join(", ")}, and a release holds only one.`);
|
|
398
|
+
seen.add(identity);
|
|
399
|
+
}
|
|
400
|
+
return files;
|
|
401
|
+
}
|
|
402
|
+
async function uploadFile(client, file, io) {
|
|
403
|
+
io.stderr.write(`Uploading ${file.name}
|
|
404
|
+
`);
|
|
405
|
+
const { key } = await client.downloads.upload(await fs.openAsBlob(file.path, { type: file.contentType }), {
|
|
406
|
+
contentType: file.contentType,
|
|
407
|
+
onProgress: ({ uploadedBytes, totalBytes }) => {
|
|
408
|
+
if (uploadedBytes < totalBytes)
|
|
409
|
+
io.stderr.write(` ${formatBytes(uploadedBytes)} of ${formatBytes(totalBytes)} (${Math.floor(uploadedBytes / totalBytes * 100)}%)
|
|
410
|
+
`);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
return key;
|
|
414
|
+
}
|
|
415
|
+
async function retryRace(io, write) {
|
|
416
|
+
for (let attempt = 1; ; attempt++) {
|
|
417
|
+
try {
|
|
418
|
+
return await write();
|
|
419
|
+
} catch (error) {
|
|
420
|
+
const status = error instanceof MoonbaseError ? error.problemDetails?.status : void 0;
|
|
421
|
+
if (attempt >= raceAttempts || status === void 0 || status < 500)
|
|
422
|
+
throw error;
|
|
423
|
+
const pause = 500 + Math.floor(Math.random() * 2500);
|
|
424
|
+
await (io.wait?.(pause) ?? new Promise((resolve) => setTimeout(resolve, pause)));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function findRelease(client, productId, version2) {
|
|
429
|
+
let page = await client.products.queryReleases(productId, { pageSize: 100 });
|
|
430
|
+
while (true) {
|
|
431
|
+
const found = page.items.find((release2) => release2.version === version2);
|
|
432
|
+
if (found || !page.hasMore)
|
|
433
|
+
return found;
|
|
434
|
+
page = await page.getNext();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function describeError(error) {
|
|
438
|
+
if (error instanceof NotAuthenticatedError)
|
|
439
|
+
return "Moonbase did not accept the API key.";
|
|
440
|
+
if (error instanceof MoonbaseError && error.problemDetails)
|
|
441
|
+
return error.problemDetails.detail ?? `${error.problemDetails.title} (HTTP ${error.problemDetails.status})`;
|
|
442
|
+
return error instanceof Error ? error.message : String(error);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/cli.ts
|
|
446
|
+
main(process2.argv.slice(2), { env: process2.env, stdout: process2.stdout, stderr: process2.stderr }).then((exitCode) => {
|
|
447
|
+
process2.exitCode = exitCode;
|
|
448
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moonbase.sh/cli",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.0-next-20261001173449",
|
|
5
|
+
"description": "Publish product releases to Moonbase.sh from the command line or a CI job",
|
|
6
|
+
"author": "Tobias Lønnerød Madsen <m@dsen.tv>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"bin": {
|
|
9
|
+
"moonbase": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/**"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20.0.0"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@moonbase.sh/api": "0.0.0-next-20261001173449"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^20.19.33",
|
|
22
|
+
"tsup": "^8.5.0",
|
|
23
|
+
"typescript": "~5.8.3",
|
|
24
|
+
"vitest": "^2.1.4"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup src/cli.ts --format esm --target node20",
|
|
28
|
+
"dev": "tsup src/cli.ts --format esm --target node20 --watch",
|
|
29
|
+
"type-check": "tsc --noEmit -p tsconfig.typecheck.json",
|
|
30
|
+
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --cache-strategy content",
|
|
31
|
+
"lint:fix": "eslint . --cache --cache-location node_modules/.cache/eslint/ --cache-strategy content --fix",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:integration": "TEST_INTEGRATION=true vitest run"
|
|
35
|
+
}
|
|
36
|
+
}
|