@userland.fun/cli 0.1.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 +86 -0
- package/dist/index.js +927 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Userland CLI
|
|
2
|
+
|
|
3
|
+
This directory is the source for the public `@userland.fun/cli` npm package.
|
|
4
|
+
|
|
5
|
+
Docs:
|
|
6
|
+
|
|
7
|
+
- https://docs.userland.fun/llms.txt
|
|
8
|
+
- https://docs.userland.fun/reference/cli
|
|
9
|
+
- https://docs.userland.fun/guides/troubleshooting
|
|
10
|
+
|
|
11
|
+
Install globally:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install -g @userland.fun/cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then run:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
userland signup --username <username>
|
|
21
|
+
userland login --username <username>
|
|
22
|
+
userland auth status
|
|
23
|
+
userland auth save-key --username <username> --api-key <api-key>
|
|
24
|
+
userland apps publish examples/<example-slug>
|
|
25
|
+
userland apps list
|
|
26
|
+
userland apps releases <app-id>
|
|
27
|
+
userland apps rollback <app-id> <release-id>
|
|
28
|
+
userland apps secrets set <app-id> <NAME> --value <value>
|
|
29
|
+
userland apps events <app-id>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
From this repo, the same commands can be run from source:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm run userland -- signup --username <username>
|
|
36
|
+
npm run userland -- login --username <username>
|
|
37
|
+
npm run userland -- auth status
|
|
38
|
+
npm run userland -- auth save-key --username <username> --api-key <api-key>
|
|
39
|
+
npm run userland -- apps publish examples/<example-slug>
|
|
40
|
+
npm run userland -- apps list
|
|
41
|
+
npm run userland -- apps releases <app-id>
|
|
42
|
+
npm run userland -- apps rollback <app-id> <release-id>
|
|
43
|
+
npm run userland -- apps secrets set <app-id> <NAME> --value <value>
|
|
44
|
+
npm run userland -- apps events <app-id>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`signup`, `login`, and `auth save-key` save the API key to `~/.userland/credentials.json` with `0600` permissions. Account username and password are stored in the OS keychain: macOS Keychain, Windows Credential Manager, or Linux Secret Service through `secret-tool`. App commands prefer `USERLAND_API_KEY` when it is set, then fall back to the saved API key.
|
|
48
|
+
|
|
49
|
+
## Validation
|
|
50
|
+
|
|
51
|
+
Build and inspect the publish tarball:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm run cli:build
|
|
55
|
+
npm run cli:pack
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run command-level CLI tests against a mocked API:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npm run cli:test
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run the full public repo validation suite:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npm run typecheck
|
|
68
|
+
npm test
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Sync and release policy
|
|
72
|
+
|
|
73
|
+
For launch, this `cli/` directory is the public CLI source of truth for agents and publishes as `@userland.fun/cli`. When changing the CLI:
|
|
74
|
+
|
|
75
|
+
1. Update `cli/src/index.ts`, this README, and `https://docs.userland.fun/reference/cli` together.
|
|
76
|
+
2. Add or update mocked command tests in `cli/tests`.
|
|
77
|
+
3. Run `npm run typecheck`, `npm run cli:test`, and `npm test`.
|
|
78
|
+
4. Update the public repo changelog and the docs changelog.
|
|
79
|
+
|
|
80
|
+
Compatibility:
|
|
81
|
+
|
|
82
|
+
| CLI package | API version | Distribution |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `@userland.fun/cli` | Userland API v0 | `npm install -g @userland.fun/cli` |
|
|
85
|
+
|
|
86
|
+
Do not commit API keys or app secrets.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
const DEFAULT_API_BASE_URL = "https://api.userland.fun";
|
|
8
|
+
const KEYCHAIN_SERVICE = "fun.userland.cli";
|
|
9
|
+
const KEYCHAIN_ACCOUNT = "default";
|
|
10
|
+
async function main() {
|
|
11
|
+
const [command, ...args] = process.argv.slice(2);
|
|
12
|
+
if (command === "apps") {
|
|
13
|
+
await appsCommand(args);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (command === "auth") {
|
|
17
|
+
await authCommand(args);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (command === "signup") {
|
|
21
|
+
await signupCommand(args);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (command === "login") {
|
|
25
|
+
await loginCommand(args);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (command === "publish") {
|
|
29
|
+
await publishCommand(args);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (command === "releases" || command === "versions") {
|
|
33
|
+
await releasesCommand(args);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
usage(1);
|
|
37
|
+
}
|
|
38
|
+
async function appsCommand(args) {
|
|
39
|
+
const [subcommand, ...rest] = args;
|
|
40
|
+
if (subcommand === "publish") {
|
|
41
|
+
await publishCommand(rest);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (subcommand === "list") {
|
|
45
|
+
await listAppsCommand();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (subcommand === "releases") {
|
|
49
|
+
await releasesCommand(rest);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (subcommand === "rollback") {
|
|
53
|
+
await rollbackCommand(rest);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (subcommand === "secrets" && rest[0] === "set") {
|
|
57
|
+
await setSecretCommand(rest.slice(1));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (subcommand === "events") {
|
|
61
|
+
await eventsCommand(rest);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
usage(1);
|
|
65
|
+
}
|
|
66
|
+
async function authCommand(args) {
|
|
67
|
+
const [subcommand, ...rest] = args;
|
|
68
|
+
if (subcommand === "signup") {
|
|
69
|
+
await signupCommand(rest);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (subcommand === "login") {
|
|
73
|
+
await loginCommand(rest);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (subcommand === "status") {
|
|
77
|
+
await authStatusCommand();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (subcommand === "save-key") {
|
|
81
|
+
await saveKeyCommand(rest);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
usage(1);
|
|
85
|
+
}
|
|
86
|
+
async function signupCommand(args) {
|
|
87
|
+
const options = parseAuthOptions(args);
|
|
88
|
+
const username = options.username ?? (await promptRequired("Username: "));
|
|
89
|
+
const password = options.password ?? (await promptPassword("Password: "));
|
|
90
|
+
const body = { username, password };
|
|
91
|
+
if (options.email) {
|
|
92
|
+
body.email = options.email;
|
|
93
|
+
}
|
|
94
|
+
const response = await unauthenticatedApiFetch("/v0/accounts", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
body: JSON.stringify(body)
|
|
97
|
+
});
|
|
98
|
+
if (options.save !== false) {
|
|
99
|
+
await saveAccountCredentials({ username: response.username, password });
|
|
100
|
+
const filePath = await saveCredentials({
|
|
101
|
+
api_key: response.api_key,
|
|
102
|
+
api_base_url: await apiBaseUrl()
|
|
103
|
+
});
|
|
104
|
+
console.log(`Created Userland account ${response.username}`);
|
|
105
|
+
console.log(`Saved API key to ${filePath}`);
|
|
106
|
+
console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
console.log(`Created Userland account ${response.username}`);
|
|
110
|
+
console.log(`api_key=${response.api_key}`);
|
|
111
|
+
}
|
|
112
|
+
async function loginCommand(args) {
|
|
113
|
+
const options = parseAuthOptions(args);
|
|
114
|
+
const storedAccount = await readAccountCredentials();
|
|
115
|
+
const username = options.username ?? storedAccount?.username ?? (await promptRequired("Username: "));
|
|
116
|
+
const password = options.password ?? storedAccount?.password ?? (await promptPassword("Password: "));
|
|
117
|
+
const response = await unauthenticatedApiFetch("/v0/auth/token", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
body: JSON.stringify({ username, password })
|
|
120
|
+
});
|
|
121
|
+
if (options.save !== false) {
|
|
122
|
+
await saveAccountCredentials({ username, password });
|
|
123
|
+
const filePath = await saveCredentials({
|
|
124
|
+
api_key: response.api_key,
|
|
125
|
+
api_base_url: await apiBaseUrl()
|
|
126
|
+
});
|
|
127
|
+
console.log(`Saved API key to ${filePath}`);
|
|
128
|
+
console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(`api_key=${response.api_key}`);
|
|
132
|
+
}
|
|
133
|
+
async function authStatusCommand() {
|
|
134
|
+
const credentials = await readCredentials();
|
|
135
|
+
const account = await readAccountCredentials();
|
|
136
|
+
const filePath = credentialsPath();
|
|
137
|
+
const apiKeySource = process.env.USERLAND_API_KEY ? "env" : credentials?.api_key ? "file" : "missing";
|
|
138
|
+
console.log(`api_base_url=${await apiBaseUrl(credentials)}`);
|
|
139
|
+
console.log(`api_key=${apiKeySource}`);
|
|
140
|
+
console.log(`credentials_file=${filePath}`);
|
|
141
|
+
console.log(`account=${account ? "keychain" : "missing"}`);
|
|
142
|
+
if (account?.username) {
|
|
143
|
+
console.log(`username=${account.username}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function saveKeyCommand(args) {
|
|
147
|
+
const options = parseAuthOptions(args);
|
|
148
|
+
const username = options.username ?? (await promptRequired("Username: "));
|
|
149
|
+
const apiKey = options.apiKey ?? (await promptRequired("API key: "));
|
|
150
|
+
await saveAccountCredentials({ username, password: options.password });
|
|
151
|
+
const filePath = await saveCredentials({
|
|
152
|
+
api_key: apiKey,
|
|
153
|
+
api_base_url: await apiBaseUrl()
|
|
154
|
+
});
|
|
155
|
+
console.log(`Saved API key to ${filePath}`);
|
|
156
|
+
console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
|
|
157
|
+
}
|
|
158
|
+
async function publishCommand(args) {
|
|
159
|
+
const dir = args[0];
|
|
160
|
+
const options = parseOptions(args.slice(1));
|
|
161
|
+
if (!dir) {
|
|
162
|
+
usage(1);
|
|
163
|
+
}
|
|
164
|
+
const body = await readPublishDirectory(dir, options);
|
|
165
|
+
const response = await apiFetch(options.app ? `/v0/apps/${options.app}` : "/v0/apps", {
|
|
166
|
+
method: "PUT",
|
|
167
|
+
body: JSON.stringify(body)
|
|
168
|
+
});
|
|
169
|
+
console.log(`Published ${response.origin}`);
|
|
170
|
+
console.log(`app_id=${response.app_id}`);
|
|
171
|
+
console.log(`release_id=${response.release_id}`);
|
|
172
|
+
console.log(`previous_release_id=${response.previous_release_id ?? ""}`);
|
|
173
|
+
console.log(`activation_status=${response.activation.status}`);
|
|
174
|
+
if (response.activation.reasons.length > 0) {
|
|
175
|
+
console.log(`activation_reasons=${response.activation.reasons.join("; ")}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function listAppsCommand() {
|
|
179
|
+
const response = await apiFetch("/v0/apps", {
|
|
180
|
+
method: "GET"
|
|
181
|
+
});
|
|
182
|
+
for (const app of response.apps) {
|
|
183
|
+
console.log(`${app.app_id}\t${app.live_release_id ?? ""}\t${app.updated_at}\t${app.name}\t${app.origin}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function releasesCommand(args) {
|
|
187
|
+
const appId = args[0];
|
|
188
|
+
if (!appId) {
|
|
189
|
+
usage(1);
|
|
190
|
+
}
|
|
191
|
+
const response = await apiFetch(`/v0/apps/${appId}/releases`, {
|
|
192
|
+
method: "GET"
|
|
193
|
+
});
|
|
194
|
+
for (const release of response.releases) {
|
|
195
|
+
const live = release.is_live ? " live" : "";
|
|
196
|
+
console.log(`${release.release_id}${live}\t${release.activation_status}\t${release.created_at}\t${release.message ?? ""}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function rollbackCommand(args) {
|
|
200
|
+
const [appId, releaseId] = args;
|
|
201
|
+
if (!appId || !releaseId) {
|
|
202
|
+
usage(1);
|
|
203
|
+
}
|
|
204
|
+
const response = await apiFetch(`/v0/apps/${appId}/rollback`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
body: JSON.stringify({ release_id: releaseId })
|
|
207
|
+
});
|
|
208
|
+
console.log(`Rolled back ${response.origin}`);
|
|
209
|
+
console.log(`app_id=${response.app_id}`);
|
|
210
|
+
console.log(`release_id=${response.release_id}`);
|
|
211
|
+
console.log(`previous_release_id=${response.previous_release_id ?? ""}`);
|
|
212
|
+
console.log(`status=${response.status}`);
|
|
213
|
+
}
|
|
214
|
+
async function setSecretCommand(args) {
|
|
215
|
+
const [appId, name, ...optionArgs] = args;
|
|
216
|
+
if (!appId || !name) {
|
|
217
|
+
usage(1);
|
|
218
|
+
}
|
|
219
|
+
const options = parseSecretSetOptions(optionArgs);
|
|
220
|
+
const value = options.value ?? (await readStdin()).trimEnd();
|
|
221
|
+
if (!value) {
|
|
222
|
+
throw new Error("Secret value is required on stdin or with --value.");
|
|
223
|
+
}
|
|
224
|
+
const response = await apiFetch(`/v0/apps/${appId}/secrets/${name}`, {
|
|
225
|
+
method: "PUT",
|
|
226
|
+
body: JSON.stringify({ value })
|
|
227
|
+
});
|
|
228
|
+
console.log(`secret=${response.name}`);
|
|
229
|
+
console.log(`present=${response.present}`);
|
|
230
|
+
console.log(`updated_at=${response.updated_at}`);
|
|
231
|
+
}
|
|
232
|
+
async function eventsCommand(args) {
|
|
233
|
+
const appId = args[0];
|
|
234
|
+
if (!appId) {
|
|
235
|
+
usage(1);
|
|
236
|
+
}
|
|
237
|
+
const options = parseEventsOptions(args.slice(1));
|
|
238
|
+
const params = new URLSearchParams();
|
|
239
|
+
if (options.type)
|
|
240
|
+
params.set("type", options.type);
|
|
241
|
+
if (options.severity)
|
|
242
|
+
params.set("severity", options.severity);
|
|
243
|
+
if (options.releaseId)
|
|
244
|
+
params.set("release_id", options.releaseId);
|
|
245
|
+
if (options.limit)
|
|
246
|
+
params.set("limit", options.limit);
|
|
247
|
+
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
248
|
+
const response = await apiFetch(`/v0/apps/${appId}/events${suffix}`, {
|
|
249
|
+
method: "GET"
|
|
250
|
+
});
|
|
251
|
+
for (const event of response.events) {
|
|
252
|
+
console.log(`${event.created_at}\t${event.severity}\t${event.type}\t${event.release_id ?? ""}\t${event.message}`);
|
|
253
|
+
}
|
|
254
|
+
if (response.cursor) {
|
|
255
|
+
console.log(`cursor=${response.cursor}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function readPublishDirectory(rootDir, options) {
|
|
259
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
260
|
+
const stat = await fs.stat(absoluteRoot).catch(() => null);
|
|
261
|
+
if (!stat?.isDirectory()) {
|
|
262
|
+
throw new Error(`Directory not found: ${rootDir}`);
|
|
263
|
+
}
|
|
264
|
+
const manifest = await readManifest(absoluteRoot);
|
|
265
|
+
const files = await readReleaseFiles(absoluteRoot, manifest);
|
|
266
|
+
const app = objectValue(manifest.app) ?? {
|
|
267
|
+
name: path.basename(absoluteRoot)
|
|
268
|
+
};
|
|
269
|
+
const runtime = objectValue(manifest.runtime) ?? {
|
|
270
|
+
static_root: "public",
|
|
271
|
+
fallback: "index.html"
|
|
272
|
+
};
|
|
273
|
+
const resources = objectValue(manifest.resources) ?? {};
|
|
274
|
+
const provenance = objectValue(manifest.provenance) ?? {};
|
|
275
|
+
return {
|
|
276
|
+
app,
|
|
277
|
+
runtime,
|
|
278
|
+
resources,
|
|
279
|
+
files,
|
|
280
|
+
message: options.message ?? stringValue(manifest.message),
|
|
281
|
+
provenance
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async function readReleaseFiles(rootDir, manifest) {
|
|
285
|
+
const manifestFiles = Array.isArray(manifest.files) ? manifest.files : null;
|
|
286
|
+
const publishFiles = manifestFiles && manifestFiles.length > 0
|
|
287
|
+
? manifestFiles.map(async (entry) => {
|
|
288
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
289
|
+
throw new Error("manifest.userland.json files entries must be objects.");
|
|
290
|
+
}
|
|
291
|
+
const file = entry;
|
|
292
|
+
if (typeof file.path !== "string") {
|
|
293
|
+
throw new Error("manifest.userland.json files entries require path.");
|
|
294
|
+
}
|
|
295
|
+
const contents = await fs.readFile(path.join(rootDir, file.path));
|
|
296
|
+
return {
|
|
297
|
+
path: file.path,
|
|
298
|
+
content_type: typeof file.content_type === "string" ? file.content_type : contentTypeForPath(file.path),
|
|
299
|
+
content_base64: contents.toString("base64")
|
|
300
|
+
};
|
|
301
|
+
})
|
|
302
|
+
: (await walk(rootDir))
|
|
303
|
+
.filter((filePath) => !isManifestFile(filePath))
|
|
304
|
+
.sort()
|
|
305
|
+
.map(async (filePath) => {
|
|
306
|
+
const relativePath = path.relative(rootDir, filePath).split(path.sep).join("/");
|
|
307
|
+
const contents = await fs.readFile(filePath);
|
|
308
|
+
return {
|
|
309
|
+
path: relativePath,
|
|
310
|
+
content_type: contentTypeForPath(relativePath),
|
|
311
|
+
content_base64: contents.toString("base64")
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
const files = await Promise.all(publishFiles);
|
|
315
|
+
if (files.length === 0) {
|
|
316
|
+
throw new Error("Publish directory must contain at least one file.");
|
|
317
|
+
}
|
|
318
|
+
return files;
|
|
319
|
+
}
|
|
320
|
+
async function readManifest(rootDir) {
|
|
321
|
+
const userlandManifestPath = path.join(rootDir, "manifest.userland.json");
|
|
322
|
+
const legacyManifestPath = path.join(rootDir, "manifest.json");
|
|
323
|
+
let manifestPath = userlandManifestPath;
|
|
324
|
+
let contents = await fs.readFile(manifestPath, "utf8").catch((error) => {
|
|
325
|
+
if (error.code === "ENOENT") {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
});
|
|
330
|
+
if (!contents) {
|
|
331
|
+
manifestPath = legacyManifestPath;
|
|
332
|
+
contents = await fs.readFile(manifestPath, "utf8").catch((error) => {
|
|
333
|
+
if (error.code === "ENOENT") {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
throw error;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (!contents) {
|
|
340
|
+
return {};
|
|
341
|
+
}
|
|
342
|
+
const parsed = JSON.parse(contents);
|
|
343
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
344
|
+
throw new Error("manifest.json must contain a JSON object.");
|
|
345
|
+
}
|
|
346
|
+
return parsed;
|
|
347
|
+
}
|
|
348
|
+
function isManifestFile(filePath) {
|
|
349
|
+
const basename = path.basename(filePath);
|
|
350
|
+
return basename === "manifest.userland.json" || basename === "manifest.json";
|
|
351
|
+
}
|
|
352
|
+
async function walk(dir) {
|
|
353
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
354
|
+
const files = await Promise.all(entries.map(async (entry) => {
|
|
355
|
+
const entryPath = path.join(dir, entry.name);
|
|
356
|
+
if (entry.isDirectory()) {
|
|
357
|
+
return await walk(entryPath);
|
|
358
|
+
}
|
|
359
|
+
if (entry.isFile()) {
|
|
360
|
+
return [entryPath];
|
|
361
|
+
}
|
|
362
|
+
return [];
|
|
363
|
+
}));
|
|
364
|
+
return files.flat();
|
|
365
|
+
}
|
|
366
|
+
async function apiFetch(apiPath, init) {
|
|
367
|
+
const credentials = await readCredentials();
|
|
368
|
+
const apiKey = process.env.USERLAND_API_KEY ?? credentials?.api_key;
|
|
369
|
+
if (!apiKey) {
|
|
370
|
+
throw new Error("USERLAND_API_KEY is required. Run `userland signup` or `userland login` to save credentials.");
|
|
371
|
+
}
|
|
372
|
+
const baseUrl = await apiBaseUrl(credentials);
|
|
373
|
+
return await requestJson(baseUrl, apiPath, {
|
|
374
|
+
...init,
|
|
375
|
+
headers: {
|
|
376
|
+
authorization: `Bearer ${apiKey}`,
|
|
377
|
+
...init.headers
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async function unauthenticatedApiFetch(apiPath, init) {
|
|
382
|
+
return await requestJson(await apiBaseUrl(), apiPath, init);
|
|
383
|
+
}
|
|
384
|
+
async function requestJson(baseUrl, apiPath, init) {
|
|
385
|
+
const response = await fetch(`${baseUrl.replace(/\/$/u, "")}${apiPath}`, {
|
|
386
|
+
...init,
|
|
387
|
+
headers: {
|
|
388
|
+
"content-type": "application/json",
|
|
389
|
+
...init.headers
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
const text = await response.text();
|
|
393
|
+
const body = text ? JSON.parse(text) : undefined;
|
|
394
|
+
if (!response.ok) {
|
|
395
|
+
const message = errorMessage(body) ?? response.statusText;
|
|
396
|
+
throw new Error(`API ${response.status}: ${message}`);
|
|
397
|
+
}
|
|
398
|
+
return body;
|
|
399
|
+
}
|
|
400
|
+
async function apiBaseUrl(credentials) {
|
|
401
|
+
return process.env.USERLAND_API_BASE_URL ?? credentials?.api_base_url ?? (await readCredentials())?.api_base_url ?? DEFAULT_API_BASE_URL;
|
|
402
|
+
}
|
|
403
|
+
function credentialsPath() {
|
|
404
|
+
return process.env.USERLAND_CREDENTIALS_FILE ?? path.join(os.homedir(), ".userland", "credentials.json");
|
|
405
|
+
}
|
|
406
|
+
async function readCredentials() {
|
|
407
|
+
const filePath = credentialsPath();
|
|
408
|
+
const contents = await fs.readFile(filePath, "utf8").catch((error) => {
|
|
409
|
+
if (error.code === "ENOENT") {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
});
|
|
414
|
+
if (!contents) {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
const parsed = JSON.parse(contents);
|
|
418
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
419
|
+
throw new Error(`Credentials file must contain a JSON object: ${filePath}`);
|
|
420
|
+
}
|
|
421
|
+
const credentials = parsed;
|
|
422
|
+
return {
|
|
423
|
+
api_base_url: stringValue(credentials.api_base_url),
|
|
424
|
+
api_key: stringValue(credentials.api_key),
|
|
425
|
+
updated_at: stringValue(credentials.updated_at)
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function saveCredentials(update) {
|
|
429
|
+
const filePath = credentialsPath();
|
|
430
|
+
const existing = (await readCredentials()) ?? {};
|
|
431
|
+
const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
|
|
432
|
+
const credentials = {
|
|
433
|
+
...existing,
|
|
434
|
+
...sanitizedUpdate,
|
|
435
|
+
updated_at: new Date().toISOString()
|
|
436
|
+
};
|
|
437
|
+
const dir = path.dirname(filePath);
|
|
438
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
439
|
+
await fs.chmod(dir, 0o700).catch(() => undefined);
|
|
440
|
+
await fs.writeFile(filePath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
441
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
442
|
+
return filePath;
|
|
443
|
+
}
|
|
444
|
+
async function readAccountCredentials() {
|
|
445
|
+
const raw = await keychainGetSecret().catch((error) => {
|
|
446
|
+
if (error instanceof KeychainUnavailableError) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
});
|
|
451
|
+
if (!raw) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
const parsed = JSON.parse(raw);
|
|
455
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
456
|
+
throw new Error("Stored Userland account credentials are malformed.");
|
|
457
|
+
}
|
|
458
|
+
const credentials = parsed;
|
|
459
|
+
const username = stringValue(credentials.username);
|
|
460
|
+
const password = stringValue(credentials.password);
|
|
461
|
+
return username || password ? { username, password } : undefined;
|
|
462
|
+
}
|
|
463
|
+
async function saveAccountCredentials(update) {
|
|
464
|
+
const existing = (await readAccountCredentials()) ?? {};
|
|
465
|
+
const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
|
|
466
|
+
const credentials = {
|
|
467
|
+
...existing,
|
|
468
|
+
...sanitizedUpdate
|
|
469
|
+
};
|
|
470
|
+
if (!credentials.username && !credentials.password) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
await keychainSetSecret(JSON.stringify(credentials));
|
|
474
|
+
}
|
|
475
|
+
function accountCredentialStoreLabel() {
|
|
476
|
+
return process.env.USERLAND_KEYCHAIN_FILE ? "test keychain" : "OS keychain";
|
|
477
|
+
}
|
|
478
|
+
class KeychainUnavailableError extends Error {
|
|
479
|
+
}
|
|
480
|
+
async function keychainGetSecret() {
|
|
481
|
+
const testKeychainFile = process.env.USERLAND_KEYCHAIN_FILE;
|
|
482
|
+
if (testKeychainFile) {
|
|
483
|
+
return await fileKeychainGet(testKeychainFile);
|
|
484
|
+
}
|
|
485
|
+
if (process.platform === "darwin") {
|
|
486
|
+
const result = await runCommand("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"]);
|
|
487
|
+
if (result.code === 44) {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
assertCommandOk("security", result);
|
|
491
|
+
return result.stdout.trimEnd();
|
|
492
|
+
}
|
|
493
|
+
if (process.platform === "linux") {
|
|
494
|
+
const result = await runCommand("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT]);
|
|
495
|
+
if (result.code === 1) {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
assertCommandOk("secret-tool", result);
|
|
499
|
+
return result.stdout.trimEnd();
|
|
500
|
+
}
|
|
501
|
+
if (process.platform === "win32") {
|
|
502
|
+
const result = await runPowerShell(windowsCredentialReadScript());
|
|
503
|
+
if (result.code === 2) {
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
assertCommandOk("powershell", result);
|
|
507
|
+
return result.stdout.trimEnd();
|
|
508
|
+
}
|
|
509
|
+
throw new KeychainUnavailableError(`OS keychain is not supported on ${process.platform}.`);
|
|
510
|
+
}
|
|
511
|
+
async function keychainSetSecret(secret) {
|
|
512
|
+
const testKeychainFile = process.env.USERLAND_KEYCHAIN_FILE;
|
|
513
|
+
if (testKeychainFile) {
|
|
514
|
+
await fileKeychainSet(testKeychainFile, secret);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (process.platform === "darwin") {
|
|
518
|
+
assertCommandOk("security", await runCommand("security", ["add-generic-password", "-U", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w", secret]));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (process.platform === "linux") {
|
|
522
|
+
assertCommandOk("secret-tool", await runCommand("secret-tool", ["store", "--label", "Userland CLI", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT], secret));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (process.platform === "win32") {
|
|
526
|
+
assertCommandOk("powershell", await runPowerShell(windowsCredentialWriteScript(), secret));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
throw new KeychainUnavailableError(`OS keychain is not supported on ${process.platform}.`);
|
|
530
|
+
}
|
|
531
|
+
async function fileKeychainGet(filePath) {
|
|
532
|
+
const contents = await fs.readFile(filePath, "utf8").catch((error) => {
|
|
533
|
+
if (error.code === "ENOENT") {
|
|
534
|
+
return undefined;
|
|
535
|
+
}
|
|
536
|
+
throw error;
|
|
537
|
+
});
|
|
538
|
+
if (!contents) {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
const parsed = JSON.parse(contents);
|
|
542
|
+
return parsed[`${KEYCHAIN_SERVICE}:${KEYCHAIN_ACCOUNT}`];
|
|
543
|
+
}
|
|
544
|
+
async function fileKeychainSet(filePath, secret) {
|
|
545
|
+
const existing = await fs.readFile(filePath, "utf8").catch((error) => {
|
|
546
|
+
if (error.code === "ENOENT") {
|
|
547
|
+
return "{}";
|
|
548
|
+
}
|
|
549
|
+
throw error;
|
|
550
|
+
});
|
|
551
|
+
const parsed = JSON.parse(existing);
|
|
552
|
+
parsed[`${KEYCHAIN_SERVICE}:${KEYCHAIN_ACCOUNT}`] = secret;
|
|
553
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
554
|
+
await fs.writeFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
555
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
556
|
+
}
|
|
557
|
+
async function runCommand(command, args, stdin, env) {
|
|
558
|
+
return await new Promise((resolve, reject) => {
|
|
559
|
+
const child = spawn(command, args, { env: env ? { ...process.env, ...env } : process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
560
|
+
const stdout = [];
|
|
561
|
+
const stderr = [];
|
|
562
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
563
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
564
|
+
child.on("error", (error) => {
|
|
565
|
+
if (error.code === "ENOENT") {
|
|
566
|
+
reject(new KeychainUnavailableError(`${command} is required for OS keychain access.`));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
reject(error);
|
|
570
|
+
});
|
|
571
|
+
child.on("close", (code) => {
|
|
572
|
+
resolve({
|
|
573
|
+
code,
|
|
574
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
575
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
child.stdin.end(stdin ?? "");
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async function runPowerShell(script, stdin) {
|
|
582
|
+
const env = stdin === undefined ? undefined : { USERLAND_KEYCHAIN_SECRET: stdin };
|
|
583
|
+
return await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "-"], scriptWithInput(script), env);
|
|
584
|
+
}
|
|
585
|
+
function scriptWithInput(script) {
|
|
586
|
+
return `$ErrorActionPreference = "Stop"\n${script}`;
|
|
587
|
+
}
|
|
588
|
+
function assertCommandOk(command, result) {
|
|
589
|
+
if (result.code !== 0) {
|
|
590
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code ?? "unknown"}`;
|
|
591
|
+
throw new Error(`${command} failed while accessing the OS keychain: ${detail}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function windowsCredentialWriteScript() {
|
|
595
|
+
return `
|
|
596
|
+
Add-Type -TypeDefinition @"
|
|
597
|
+
using System;
|
|
598
|
+
using System.ComponentModel;
|
|
599
|
+
using System.Runtime.InteropServices;
|
|
600
|
+
using System.Text;
|
|
601
|
+
|
|
602
|
+
public static class UserlandCredential {
|
|
603
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
604
|
+
private struct Credential {
|
|
605
|
+
public UInt32 Flags;
|
|
606
|
+
public UInt32 Type;
|
|
607
|
+
public string TargetName;
|
|
608
|
+
public string Comment;
|
|
609
|
+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
610
|
+
public UInt32 CredentialBlobSize;
|
|
611
|
+
public IntPtr CredentialBlob;
|
|
612
|
+
public UInt32 Persist;
|
|
613
|
+
public UInt32 AttributeCount;
|
|
614
|
+
public IntPtr Attributes;
|
|
615
|
+
public string TargetAlias;
|
|
616
|
+
public string UserName;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
[DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
620
|
+
private static extern bool CredWrite(ref Credential credential, UInt32 flags);
|
|
621
|
+
|
|
622
|
+
public static void Write(string target, string username, string secret) {
|
|
623
|
+
byte[] bytes = Encoding.Unicode.GetBytes(secret);
|
|
624
|
+
IntPtr blob = Marshal.AllocCoTaskMem(bytes.Length);
|
|
625
|
+
try {
|
|
626
|
+
Marshal.Copy(bytes, 0, blob, bytes.Length);
|
|
627
|
+
Credential credential = new Credential();
|
|
628
|
+
credential.Type = 1;
|
|
629
|
+
credential.TargetName = target;
|
|
630
|
+
credential.UserName = username;
|
|
631
|
+
credential.CredentialBlob = blob;
|
|
632
|
+
credential.CredentialBlobSize = (UInt32)bytes.Length;
|
|
633
|
+
credential.Persist = 2;
|
|
634
|
+
if (!CredWrite(ref credential, 0)) {
|
|
635
|
+
throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
636
|
+
}
|
|
637
|
+
} finally {
|
|
638
|
+
Marshal.FreeCoTaskMem(blob);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
"@
|
|
643
|
+
$secret = [Environment]::GetEnvironmentVariable("USERLAND_KEYCHAIN_SECRET")
|
|
644
|
+
[UserlandCredential]::Write(${JSON.stringify(KEYCHAIN_SERVICE)}, ${JSON.stringify(KEYCHAIN_ACCOUNT)}, $secret)
|
|
645
|
+
`;
|
|
646
|
+
}
|
|
647
|
+
function windowsCredentialReadScript() {
|
|
648
|
+
return `
|
|
649
|
+
Add-Type -TypeDefinition @"
|
|
650
|
+
using System;
|
|
651
|
+
using System.ComponentModel;
|
|
652
|
+
using System.Runtime.InteropServices;
|
|
653
|
+
|
|
654
|
+
public static class UserlandCredential {
|
|
655
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
656
|
+
private struct Credential {
|
|
657
|
+
public UInt32 Flags;
|
|
658
|
+
public UInt32 Type;
|
|
659
|
+
public string TargetName;
|
|
660
|
+
public string Comment;
|
|
661
|
+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
662
|
+
public UInt32 CredentialBlobSize;
|
|
663
|
+
public IntPtr CredentialBlob;
|
|
664
|
+
public UInt32 Persist;
|
|
665
|
+
public UInt32 AttributeCount;
|
|
666
|
+
public IntPtr Attributes;
|
|
667
|
+
public string TargetAlias;
|
|
668
|
+
public string UserName;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
[DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
672
|
+
private static extern bool CredRead(string target, UInt32 type, UInt32 reservedFlag, out IntPtr credentialPtr);
|
|
673
|
+
|
|
674
|
+
[DllImport("Advapi32.dll", SetLastError = true)]
|
|
675
|
+
private static extern void CredFree(IntPtr buffer);
|
|
676
|
+
|
|
677
|
+
public static string Read(string target) {
|
|
678
|
+
IntPtr credentialPtr;
|
|
679
|
+
if (!CredRead(target, 1, 0, out credentialPtr)) {
|
|
680
|
+
int error = Marshal.GetLastWin32Error();
|
|
681
|
+
if (error == 1168) {
|
|
682
|
+
Environment.Exit(2);
|
|
683
|
+
}
|
|
684
|
+
throw new Win32Exception(error);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
Credential credential = (Credential)Marshal.PtrToStructure(credentialPtr, typeof(Credential));
|
|
689
|
+
return Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
|
|
690
|
+
} finally {
|
|
691
|
+
CredFree(credentialPtr);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
"@
|
|
696
|
+
[Console]::Out.Write([UserlandCredential]::Read(${JSON.stringify(KEYCHAIN_SERVICE)}))
|
|
697
|
+
`;
|
|
698
|
+
}
|
|
699
|
+
function parseOptions(args) {
|
|
700
|
+
const options = {};
|
|
701
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
702
|
+
const arg = args[index];
|
|
703
|
+
if (arg === "--app") {
|
|
704
|
+
options.app = args[++index];
|
|
705
|
+
}
|
|
706
|
+
else if (arg === "--message") {
|
|
707
|
+
options.message = args[++index];
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return options;
|
|
714
|
+
}
|
|
715
|
+
function parseAuthOptions(args) {
|
|
716
|
+
const options = { save: true };
|
|
717
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
718
|
+
const arg = args[index];
|
|
719
|
+
if (arg === "--username") {
|
|
720
|
+
options.username = args[++index];
|
|
721
|
+
}
|
|
722
|
+
else if (arg === "--password") {
|
|
723
|
+
options.password = args[++index];
|
|
724
|
+
}
|
|
725
|
+
else if (arg === "--email") {
|
|
726
|
+
options.email = args[++index];
|
|
727
|
+
}
|
|
728
|
+
else if (arg === "--api-key") {
|
|
729
|
+
options.apiKey = args[++index];
|
|
730
|
+
}
|
|
731
|
+
else if (arg === "--no-save") {
|
|
732
|
+
options.save = false;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return options;
|
|
739
|
+
}
|
|
740
|
+
function parseSecretSetOptions(args) {
|
|
741
|
+
const options = {};
|
|
742
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
743
|
+
const arg = args[index];
|
|
744
|
+
if (arg === "--value") {
|
|
745
|
+
options.value = args[++index];
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return options;
|
|
752
|
+
}
|
|
753
|
+
function parseEventsOptions(args) {
|
|
754
|
+
const options = {};
|
|
755
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
756
|
+
const arg = args[index];
|
|
757
|
+
if (arg === "--type") {
|
|
758
|
+
options.type = args[++index];
|
|
759
|
+
}
|
|
760
|
+
else if (arg === "--severity") {
|
|
761
|
+
options.severity = args[++index];
|
|
762
|
+
}
|
|
763
|
+
else if (arg === "--release") {
|
|
764
|
+
options.releaseId = args[++index];
|
|
765
|
+
}
|
|
766
|
+
else if (arg === "--limit") {
|
|
767
|
+
options.limit = args[++index];
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return options;
|
|
774
|
+
}
|
|
775
|
+
async function readStdin() {
|
|
776
|
+
if (process.stdin.isTTY) {
|
|
777
|
+
return "";
|
|
778
|
+
}
|
|
779
|
+
const chunks = [];
|
|
780
|
+
for await (const chunk of process.stdin) {
|
|
781
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
782
|
+
}
|
|
783
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
784
|
+
}
|
|
785
|
+
async function promptRequired(prompt) {
|
|
786
|
+
const value = await promptLine(prompt);
|
|
787
|
+
if (!value) {
|
|
788
|
+
throw new Error(`${prompt.replace(/:\s*$/u, "")} is required.`);
|
|
789
|
+
}
|
|
790
|
+
return value;
|
|
791
|
+
}
|
|
792
|
+
async function promptLine(prompt) {
|
|
793
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
794
|
+
try {
|
|
795
|
+
return (await readline.question(prompt)).trim();
|
|
796
|
+
}
|
|
797
|
+
finally {
|
|
798
|
+
readline.close();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
async function promptPassword(prompt) {
|
|
802
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
803
|
+
return await promptRequired(prompt);
|
|
804
|
+
}
|
|
805
|
+
process.stdout.write(prompt);
|
|
806
|
+
process.stdin.setRawMode(true);
|
|
807
|
+
process.stdin.resume();
|
|
808
|
+
process.stdin.setEncoding("utf8");
|
|
809
|
+
return await new Promise((resolve, reject) => {
|
|
810
|
+
let value = "";
|
|
811
|
+
const cleanup = () => {
|
|
812
|
+
process.stdin.setRawMode(false);
|
|
813
|
+
process.stdin.off("data", onData);
|
|
814
|
+
};
|
|
815
|
+
const onData = (chunk) => {
|
|
816
|
+
for (const char of chunk) {
|
|
817
|
+
if (char === "\u0003") {
|
|
818
|
+
cleanup();
|
|
819
|
+
process.stdout.write("\n");
|
|
820
|
+
reject(new Error("Interrupted."));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (char === "\r" || char === "\n" || char === "\u0004") {
|
|
824
|
+
cleanup();
|
|
825
|
+
process.stdout.write("\n");
|
|
826
|
+
if (!value) {
|
|
827
|
+
reject(new Error("Password is required."));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
resolve(value);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (char === "\u007f") {
|
|
834
|
+
value = value.slice(0, -1);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
value += char;
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
process.stdin.on("data", onData);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
function objectValue(value) {
|
|
844
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
845
|
+
}
|
|
846
|
+
function stringValue(value) {
|
|
847
|
+
return typeof value === "string" ? value : undefined;
|
|
848
|
+
}
|
|
849
|
+
function contentTypeForPath(filePath) {
|
|
850
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
851
|
+
const types = {
|
|
852
|
+
".html": "text/html; charset=utf-8",
|
|
853
|
+
".css": "text/css; charset=utf-8",
|
|
854
|
+
".js": "application/javascript; charset=utf-8",
|
|
855
|
+
".json": "application/json; charset=utf-8",
|
|
856
|
+
".svg": "image/svg+xml",
|
|
857
|
+
".png": "image/png",
|
|
858
|
+
".jpg": "image/jpeg",
|
|
859
|
+
".jpeg": "image/jpeg",
|
|
860
|
+
".gif": "image/gif",
|
|
861
|
+
".webp": "image/webp",
|
|
862
|
+
".txt": "text/plain; charset=utf-8"
|
|
863
|
+
};
|
|
864
|
+
return types[ext] ?? "application/octet-stream";
|
|
865
|
+
}
|
|
866
|
+
function errorMessage(body) {
|
|
867
|
+
if (typeof body !== "object" || body === null || !("error" in body)) {
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
const error = body.error;
|
|
871
|
+
if (typeof error !== "object" || error === null || !("message" in error)) {
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
const typedError = error;
|
|
875
|
+
const message = typeof typedError.message === "string" ? typedError.message : undefined;
|
|
876
|
+
const code = typeof typedError.code === "string" ? typedError.code : undefined;
|
|
877
|
+
if (code && message) {
|
|
878
|
+
return `${code}: ${message}`;
|
|
879
|
+
}
|
|
880
|
+
return message ?? code;
|
|
881
|
+
}
|
|
882
|
+
function usage(exitCode) {
|
|
883
|
+
console.error(`Usage:
|
|
884
|
+
userland signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
|
|
885
|
+
userland login [--username <username>] [--password <password>] [--no-save]
|
|
886
|
+
userland auth status
|
|
887
|
+
userland auth save-key --username <username> --api-key <api-key> [--password <password>]
|
|
888
|
+
userland apps publish <dir> [--app <app-id>] [--message <message>]
|
|
889
|
+
userland apps list
|
|
890
|
+
userland apps releases <app-id>
|
|
891
|
+
userland apps rollback <app-id> <release-id>
|
|
892
|
+
userland apps secrets set <app-id> <NAME> [--value <value>]
|
|
893
|
+
userland apps events <app-id> [--type <event-type>] [--severity <level>] [--release <release-id>] [--limit <n>]
|
|
894
|
+
|
|
895
|
+
Aliases:
|
|
896
|
+
userland auth signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
|
|
897
|
+
userland auth login [--username <username>] [--password <password>] [--no-save]
|
|
898
|
+
userland publish <dir> [--app <app-id>] [--message <message>]
|
|
899
|
+
userland releases <app-id>
|
|
900
|
+
|
|
901
|
+
Credentials:
|
|
902
|
+
Commands use USERLAND_API_KEY first, then ~/.userland/credentials.json for API keys.
|
|
903
|
+
Account username and password are stored in the OS keychain.
|
|
904
|
+
|
|
905
|
+
Docs:
|
|
906
|
+
https://docs.userland.fun/reference/cli
|
|
907
|
+
https://docs.userland.fun/guides/troubleshooting`);
|
|
908
|
+
process.exit(exitCode);
|
|
909
|
+
}
|
|
910
|
+
main().catch((error) => {
|
|
911
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
912
|
+
console.error(message);
|
|
913
|
+
console.error(`Docs: ${docsUrlForError(message)}`);
|
|
914
|
+
process.exit(1);
|
|
915
|
+
});
|
|
916
|
+
function docsUrlForError(message) {
|
|
917
|
+
if (message.includes("USERLAND_API_KEY") || message.includes("credentials")) {
|
|
918
|
+
return "https://docs.userland.fun/reference/cli";
|
|
919
|
+
}
|
|
920
|
+
if (message.includes("secrets") || message.includes("pending_secrets")) {
|
|
921
|
+
return "https://docs.userland.fun/guides/secrets";
|
|
922
|
+
}
|
|
923
|
+
if (message.includes("rollback")) {
|
|
924
|
+
return "https://docs.userland.fun/guides/rollback";
|
|
925
|
+
}
|
|
926
|
+
return "https://docs.userland.fun/guides/troubleshooting";
|
|
927
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@userland.fun/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Userland command-line tools for publishing and operating apps.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"userland": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "cd .. && npm run cli:build",
|
|
16
|
+
"prepack": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/dwrtz/userland-public.git",
|
|
27
|
+
"directory": "cli"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://docs.userland.fun/reference/cli",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/dwrtz/userland-public/issues"
|
|
32
|
+
}
|
|
33
|
+
}
|