@userland.fun/cli 0.1.3 → 0.3.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 +83 -7
- package/dist/index.js +664 -399
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,8 +5,8 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
const DEFAULT_API_BASE_URL = "https://api.userland.fun";
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const DEFAULT_CONSOLE_BASE_URL = "https://console.userland.fun";
|
|
9
|
+
const CLI_VERSION = "0.0.0";
|
|
10
10
|
async function main() {
|
|
11
11
|
const [command, ...args] = process.argv.slice(2);
|
|
12
12
|
if (isHelpCommand(command)) {
|
|
@@ -24,6 +24,10 @@ async function main() {
|
|
|
24
24
|
await accountsCommand(args);
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
|
+
if (command === "ops") {
|
|
28
|
+
await opsCommand(args);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
27
31
|
if (command === "signup") {
|
|
28
32
|
await signupCommand(args);
|
|
29
33
|
return;
|
|
@@ -68,6 +72,22 @@ async function appsCommand(args) {
|
|
|
68
72
|
await eventsCommand(rest);
|
|
69
73
|
return;
|
|
70
74
|
}
|
|
75
|
+
if (subcommand === "status") {
|
|
76
|
+
await appStatusCommand(rest);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (subcommand === "routes" && rest[0] === "list") {
|
|
80
|
+
await routesListCommand(rest.slice(1));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (subcommand === "slugs") {
|
|
84
|
+
await appSlugsCommand(rest);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (subcommand === "domains") {
|
|
88
|
+
await appDomainsCommand(rest);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
71
91
|
usage(1);
|
|
72
92
|
}
|
|
73
93
|
async function authCommand(args) {
|
|
@@ -88,6 +108,10 @@ async function authCommand(args) {
|
|
|
88
108
|
await saveKeyCommand(rest);
|
|
89
109
|
return;
|
|
90
110
|
}
|
|
111
|
+
if (subcommand === "logout") {
|
|
112
|
+
await logoutCommand(rest);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
91
115
|
usage(1);
|
|
92
116
|
}
|
|
93
117
|
async function accountsCommand(args) {
|
|
@@ -100,90 +124,203 @@ async function accountsCommand(args) {
|
|
|
100
124
|
await useAccountCommand(rest);
|
|
101
125
|
return;
|
|
102
126
|
}
|
|
127
|
+
if (subcommand === "status") {
|
|
128
|
+
await accountStatusCommand(rest);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (subcommand === "limits") {
|
|
132
|
+
await accountLimitsCommand(rest);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (subcommand === "downgrade" && rest[0] === "preview") {
|
|
136
|
+
await downgradePreviewCommand(rest.slice(1));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
103
139
|
usage(1);
|
|
104
140
|
}
|
|
105
|
-
async function
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (options.email) {
|
|
111
|
-
body.email = options.email;
|
|
141
|
+
async function opsCommand(args) {
|
|
142
|
+
const [scope, subcommand, id, actionOrFlag, ...rest] = args;
|
|
143
|
+
if (scope === "accounts" && subcommand === "status" && id) {
|
|
144
|
+
await printObject(await apiFetch(`/v0/ops/accounts/${encodeURIComponent(id)}/status`, { method: "GET" }));
|
|
145
|
+
return;
|
|
112
146
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
body: JSON.stringify(body)
|
|
116
|
-
});
|
|
117
|
-
if (options.save !== false) {
|
|
118
|
-
await saveAccountCredentials({ username: response.username, password });
|
|
119
|
-
const filePath = await saveCredentials({
|
|
120
|
-
api_key: response.api_key,
|
|
121
|
-
api_base_url: await apiBaseUrl(),
|
|
122
|
-
account_id: response.account_id ?? null
|
|
123
|
-
});
|
|
124
|
-
console.log(`Created Userland account ${response.username}`);
|
|
125
|
-
console.log(`Saved API key to ${filePath}`);
|
|
126
|
-
console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
|
|
147
|
+
if (scope === "accounts" && subcommand === "flag" && id && actionOrFlag) {
|
|
148
|
+
await printObject(await opsFlagCommand("accounts", id, actionOrFlag, rest, false));
|
|
127
149
|
return;
|
|
128
150
|
}
|
|
129
|
-
|
|
130
|
-
|
|
151
|
+
if (scope === "accounts" && subcommand === "clear" && id && actionOrFlag) {
|
|
152
|
+
await printObject(await opsFlagCommand("accounts", id, actionOrFlag, rest, true));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (scope === "apps" && subcommand === "status" && id) {
|
|
156
|
+
await printObject(await apiFetch(`/v0/ops/apps/${encodeURIComponent(id)}/status`, { method: "GET" }));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (scope === "apps" && subcommand === "flag" && id && actionOrFlag) {
|
|
160
|
+
await printObject(await opsFlagCommand("apps", id, actionOrFlag, rest, false));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (scope === "apps" && subcommand === "clear" && id && actionOrFlag) {
|
|
164
|
+
await printObject(await opsFlagCommand("apps", id, actionOrFlag, rest, true));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (scope === "apps" && subcommand === "takedown" && id) {
|
|
168
|
+
const options = parseReasonOptions([actionOrFlag, ...rest].filter((value) => value !== undefined));
|
|
169
|
+
await printObject(await apiFetch(`/v0/ops/apps/${encodeURIComponent(id)}/takedown`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
body: JSON.stringify(bodyWithReason(options))
|
|
172
|
+
}));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (scope === "routes" && subcommand === "disable" && id) {
|
|
176
|
+
const options = parseRouteDisableOptions([actionOrFlag, ...rest].filter((value) => value !== undefined));
|
|
177
|
+
if (!options.status) {
|
|
178
|
+
usage(1);
|
|
179
|
+
}
|
|
180
|
+
await printObject(await apiFetch(`/v0/ops/routes/${encodeURIComponent(id)}/disable`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: JSON.stringify({ status: options.status, ...bodyWithReason(options) })
|
|
183
|
+
}));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (scope === "routes" && subcommand === "enable" && id) {
|
|
187
|
+
const options = parseReasonOptions([actionOrFlag, ...rest].filter((value) => value !== undefined));
|
|
188
|
+
await printObject(await apiFetch(`/v0/ops/routes/${encodeURIComponent(id)}/enable`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: JSON.stringify(bodyWithReason(options))
|
|
191
|
+
}));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
usage(1);
|
|
195
|
+
}
|
|
196
|
+
async function signupCommand(args) {
|
|
197
|
+
const options = parseAuthOptions(args);
|
|
198
|
+
await deviceLoginCommand(options, { signupAlias: true });
|
|
131
199
|
}
|
|
132
200
|
async function loginCommand(args) {
|
|
133
201
|
const options = parseAuthOptions(args);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
202
|
+
await deviceLoginCommand(options, { signupAlias: false });
|
|
203
|
+
}
|
|
204
|
+
async function deviceLoginCommand(options, context) {
|
|
205
|
+
const credentials = await readCredentials();
|
|
206
|
+
const baseUrl = await apiBaseUrl(credentials, options.apiBaseUrl);
|
|
207
|
+
const configuredConsoleUrl = await consoleBaseUrl(credentials, options.consoleUrl);
|
|
208
|
+
const start = await requestJson(baseUrl, "/v0/auth/device/start", {
|
|
138
209
|
method: "POST",
|
|
139
|
-
body: JSON.stringify({
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
client: "userland-cli",
|
|
212
|
+
client_version: CLI_VERSION,
|
|
213
|
+
requested_capability: "api_key"
|
|
214
|
+
})
|
|
140
215
|
});
|
|
216
|
+
const verificationUrl = options.consoleUrl
|
|
217
|
+
? `${configuredConsoleUrl.replace(/\/$/u, "")}/device?code=${encodeURIComponent(start.user_code)}`
|
|
218
|
+
: start.verification_uri_complete;
|
|
219
|
+
const consoleUrl = options.consoleUrl ?? consoleUrlFromVerification(start.verification_uri, configuredConsoleUrl);
|
|
220
|
+
if (context.signupAlias) {
|
|
221
|
+
console.log("Signup uses the same browser approval flow as login. New accounts are created in the browser after email proof.");
|
|
222
|
+
}
|
|
223
|
+
if (options.email) {
|
|
224
|
+
console.log(`email_hint=${options.email}`);
|
|
225
|
+
}
|
|
226
|
+
if (!options.noBrowser) {
|
|
227
|
+
const opened = await openBrowser(verificationUrl);
|
|
228
|
+
if (opened) {
|
|
229
|
+
console.log("Opened your browser for Userland authorization.");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
console.log("Open this URL to sign in to Userland:");
|
|
233
|
+
console.log("");
|
|
234
|
+
console.log(verificationUrl);
|
|
235
|
+
console.log("");
|
|
236
|
+
console.log(`user_code=${start.user_code}`);
|
|
237
|
+
console.log("Waiting for approval...");
|
|
238
|
+
const response = await pollDeviceAuthorization(baseUrl, start);
|
|
141
239
|
if (options.save !== false) {
|
|
142
|
-
const baseUrl = await apiBaseUrl();
|
|
143
|
-
const accountId = response.account_id ?? (await discoverDefaultAccountId(response.api_key, baseUrl).catch(() => undefined));
|
|
144
|
-
await saveAccountCredentials({ username, password });
|
|
145
240
|
const filePath = await saveCredentials({
|
|
146
241
|
api_key: response.api_key,
|
|
242
|
+
api_key_id: response.api_key_id ?? null,
|
|
147
243
|
api_base_url: baseUrl,
|
|
148
|
-
|
|
244
|
+
console_url: consoleUrl,
|
|
245
|
+
username: response.username ?? null,
|
|
246
|
+
account_id: response.default_account_id ?? null
|
|
149
247
|
});
|
|
150
248
|
console.log(`Saved API key to ${filePath}`);
|
|
151
|
-
|
|
249
|
+
if (response.username) {
|
|
250
|
+
console.log(`username=${response.username}`);
|
|
251
|
+
}
|
|
252
|
+
if (response.default_account_id) {
|
|
253
|
+
console.log(`selected_account_id=${response.default_account_id}`);
|
|
254
|
+
}
|
|
152
255
|
return;
|
|
153
256
|
}
|
|
154
257
|
console.log(`api_key=${response.api_key}`);
|
|
258
|
+
if (response.api_key_id) {
|
|
259
|
+
console.log(`api_key_id=${response.api_key_id}`);
|
|
260
|
+
}
|
|
261
|
+
if (response.default_account_id) {
|
|
262
|
+
console.log(`selected_account_id=${response.default_account_id}`);
|
|
263
|
+
}
|
|
155
264
|
}
|
|
156
265
|
async function authStatusCommand() {
|
|
157
266
|
const credentials = await readCredentials();
|
|
158
|
-
const account = await readAccountCredentials();
|
|
159
267
|
const filePath = credentialsPath();
|
|
160
268
|
const apiKeySource = process.env.USERLAND_API_KEY ? "env" : credentials?.api_key ? "file" : "missing";
|
|
161
269
|
const selectedAccountId = process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
|
|
162
270
|
const accountSource = process.env.USERLAND_ACCOUNT_ID ? "env" : credentials?.account_id ? "file" : apiKeySource === "missing" ? "missing" : "default";
|
|
163
271
|
console.log(`api_base_url=${await apiBaseUrl(credentials)}`);
|
|
272
|
+
console.log(`console_url=${await consoleBaseUrl(credentials)}`);
|
|
164
273
|
console.log(`api_key=${apiKeySource}`);
|
|
165
274
|
console.log(`credentials_file=${filePath}`);
|
|
275
|
+
if (apiKeySource === "file" && credentials?.api_key_id) {
|
|
276
|
+
console.log(`api_key_id=${credentials.api_key_id}`);
|
|
277
|
+
}
|
|
166
278
|
console.log(`account=${accountSource}`);
|
|
167
279
|
if (selectedAccountId) {
|
|
168
280
|
console.log(`account_id=${selectedAccountId}`);
|
|
169
281
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
console.log(`username=${account.username}`);
|
|
282
|
+
if (apiKeySource === "file" && credentials?.username) {
|
|
283
|
+
console.log(`username=${credentials.username}`);
|
|
173
284
|
}
|
|
174
285
|
}
|
|
175
286
|
async function saveKeyCommand(args) {
|
|
176
287
|
const options = parseAuthOptions(args);
|
|
177
|
-
const username = options.username ?? (await promptRequired("Username: "));
|
|
178
288
|
const apiKey = options.apiKey ?? (await promptRequired("API key: "));
|
|
179
|
-
|
|
289
|
+
const credentials = await readCredentials();
|
|
180
290
|
const filePath = await saveCredentials({
|
|
181
291
|
api_key: apiKey,
|
|
182
|
-
|
|
183
|
-
|
|
292
|
+
api_key_id: null,
|
|
293
|
+
api_base_url: await apiBaseUrl(credentials, options.apiBaseUrl),
|
|
294
|
+
console_url: await consoleBaseUrl(credentials, options.consoleUrl),
|
|
295
|
+
username: null,
|
|
296
|
+
account_id: options.account ?? null
|
|
184
297
|
});
|
|
185
298
|
console.log(`Saved API key to ${filePath}`);
|
|
186
|
-
|
|
299
|
+
if (options.account) {
|
|
300
|
+
console.log(`selected_account_id=${options.account}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function logoutCommand(args) {
|
|
304
|
+
const options = parseAuthOptions(args);
|
|
305
|
+
const credentials = await readCredentials();
|
|
306
|
+
const filePath = credentialsPath();
|
|
307
|
+
if (options.revoke) {
|
|
308
|
+
if (credentials?.api_key && credentials.api_key_id) {
|
|
309
|
+
await requestJson(await apiBaseUrl(credentials, options.apiBaseUrl), `/v0/auth/api-keys/${encodeURIComponent(credentials.api_key_id)}`, {
|
|
310
|
+
method: "DELETE",
|
|
311
|
+
headers: {
|
|
312
|
+
authorization: `Bearer ${credentials.api_key}`
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
console.log(`revoked_api_key_id=${credentials.api_key_id}`);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
console.log("revoke=skipped api_key_id_missing");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
await fs.rm(filePath, { force: true });
|
|
322
|
+
console.log("local_credentials=removed");
|
|
323
|
+
console.log(`credentials_file=${filePath}`);
|
|
187
324
|
}
|
|
188
325
|
async function listAccountsCommand() {
|
|
189
326
|
const response = await apiFetch("/v0/accounts", {
|
|
@@ -203,6 +340,69 @@ async function useAccountCommand(args) {
|
|
|
203
340
|
console.log(`selected_account_id=${accountId}`);
|
|
204
341
|
console.log(`credentials_file=${filePath}`);
|
|
205
342
|
}
|
|
343
|
+
async function accountStatusCommand(args) {
|
|
344
|
+
const options = parseAccountOptions(args);
|
|
345
|
+
const accountId = await resolveAccountId(options.account);
|
|
346
|
+
const response = await apiFetch(`/v0/accounts/${encodeURIComponent(accountId)}/status`, {
|
|
347
|
+
method: "GET"
|
|
348
|
+
}, { accountId: options.account, accountScoped: true });
|
|
349
|
+
console.log(`account_id=${response.account_id}`);
|
|
350
|
+
if (response.plan_key)
|
|
351
|
+
console.log(`plan_key=${response.plan_key}`);
|
|
352
|
+
console.log(`billing_access_state=${response.billing_access_state}`);
|
|
353
|
+
console.log(`grace_ends_at=${response.grace_ends_at ?? ""}`);
|
|
354
|
+
if (response.suspended !== undefined)
|
|
355
|
+
console.log(`suspended=${response.suspended}`);
|
|
356
|
+
if (response.restricted !== undefined)
|
|
357
|
+
console.log(`restricted=${response.restricted}`);
|
|
358
|
+
printList("account_flags", response.account_flags ?? response.active_flags);
|
|
359
|
+
printList("reasons", response.reasons);
|
|
360
|
+
printWarnings(response.warnings);
|
|
361
|
+
}
|
|
362
|
+
async function accountLimitsCommand(args) {
|
|
363
|
+
const options = parseAccountOptions(args);
|
|
364
|
+
const accountId = await resolveAccountId(options.account);
|
|
365
|
+
const response = await apiFetch(`/v0/accounts/${encodeURIComponent(accountId)}/limits`, {
|
|
366
|
+
method: "GET"
|
|
367
|
+
}, { accountId: options.account, accountScoped: true });
|
|
368
|
+
console.log(`account_id=${response.account_id}`);
|
|
369
|
+
console.log(`plan_key=${response.plan_key}`);
|
|
370
|
+
if (response.usage_period?.period_start)
|
|
371
|
+
console.log(`usage_period_start=${response.usage_period.period_start}`);
|
|
372
|
+
if (response.usage_period?.period_end)
|
|
373
|
+
console.log(`usage_period_end=${response.usage_period.period_end}`);
|
|
374
|
+
printKeyValues("feature", response.features);
|
|
375
|
+
printKeyValues("manifest_limit", response.manifest_limits);
|
|
376
|
+
printKeyValues("deployment_limit", response.deployment_limits);
|
|
377
|
+
printKeyValues("runtime_limit", response.runtime_limits);
|
|
378
|
+
printKeyValues("release_limit", response.release_limits);
|
|
379
|
+
printKeyValues("usage_limit", response.usage_limits);
|
|
380
|
+
printKeyValues("usage", response.usage);
|
|
381
|
+
if (response.route_counts)
|
|
382
|
+
printKeyValues("route_count", response.route_counts);
|
|
383
|
+
printWarnings(response.compatibility_warnings);
|
|
384
|
+
}
|
|
385
|
+
async function downgradePreviewCommand(args) {
|
|
386
|
+
const options = parseDowngradePreviewOptions(args);
|
|
387
|
+
if (!options.to) {
|
|
388
|
+
usage(1);
|
|
389
|
+
}
|
|
390
|
+
const accountId = await resolveAccountId(options.account);
|
|
391
|
+
const params = new URLSearchParams({ plan: options.to });
|
|
392
|
+
const response = await apiFetch(`/v0/accounts/${encodeURIComponent(accountId)}/downgrade-preview?${params.toString()}`, {
|
|
393
|
+
method: "GET"
|
|
394
|
+
}, { accountId: options.account, accountScoped: true });
|
|
395
|
+
console.log(`account_id=${response.account_id}`);
|
|
396
|
+
console.log(`current_plan_key=${response.current_plan_key}`);
|
|
397
|
+
console.log(`target_plan_key=${response.target_plan_key}`);
|
|
398
|
+
console.log(`compatible=${response.compatible}`);
|
|
399
|
+
for (const violation of response.violations) {
|
|
400
|
+
console.log(`violation=${formatFields(violation)}`);
|
|
401
|
+
}
|
|
402
|
+
for (const action of response.actions) {
|
|
403
|
+
console.log(`action=${formatFields(action)}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
206
406
|
async function publishCommand(args) {
|
|
207
407
|
const dir = args[0];
|
|
208
408
|
const options = parseOptions(args.slice(1));
|
|
@@ -306,6 +506,185 @@ async function eventsCommand(args) {
|
|
|
306
506
|
console.log(`cursor=${response.cursor}`);
|
|
307
507
|
}
|
|
308
508
|
}
|
|
509
|
+
async function appStatusCommand(args) {
|
|
510
|
+
const appId = args[0];
|
|
511
|
+
if (!appId) {
|
|
512
|
+
usage(1);
|
|
513
|
+
}
|
|
514
|
+
const options = parseAccountOptions(args.slice(1));
|
|
515
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}`, {
|
|
516
|
+
method: "GET"
|
|
517
|
+
}, { accountId: options.account, accountScoped: true });
|
|
518
|
+
const state = objectValue(response.operational_state) ?? {};
|
|
519
|
+
console.log(`app_id=${response.app_id}`);
|
|
520
|
+
if (response.account_id)
|
|
521
|
+
console.log(`account_id=${response.account_id}`);
|
|
522
|
+
if (stringValue(state.billing_access_state) ?? response.billing_access_state)
|
|
523
|
+
console.log(`billing_access_state=${stringValue(state.billing_access_state) ?? response.billing_access_state}`);
|
|
524
|
+
printOptionalBoolean("suspended", state.suspended ?? response.suspended);
|
|
525
|
+
printOptionalBoolean("takedown", state.takedown ?? response.takedown);
|
|
526
|
+
printOptionalBoolean("restricted", state.restricted);
|
|
527
|
+
printOptionalBoolean("can_serve_canonical", state.can_serve_canonical ?? response.can_serve_canonical);
|
|
528
|
+
printOptionalBoolean("can_mutate", state.can_mutate ?? response.can_mutate);
|
|
529
|
+
printList("account_flags", stringArrayValue(state.account_flags) ?? response.account_flags);
|
|
530
|
+
printList("app_flags", stringArrayValue(state.app_flags) ?? response.app_flags);
|
|
531
|
+
printList("reasons", stringArrayValue(state.reasons) ?? response.reasons);
|
|
532
|
+
if (response.routes) {
|
|
533
|
+
for (const route of response.routes) {
|
|
534
|
+
printRoute(route);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function routesListCommand(args) {
|
|
539
|
+
const appId = args[0];
|
|
540
|
+
if (!appId) {
|
|
541
|
+
usage(1);
|
|
542
|
+
}
|
|
543
|
+
const options = parseAccountOptions(args.slice(1));
|
|
544
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/routes`, {
|
|
545
|
+
method: "GET"
|
|
546
|
+
}, { accountId: options.account, accountScoped: true });
|
|
547
|
+
printRoutes(response.routes);
|
|
548
|
+
}
|
|
549
|
+
async function appSlugsCommand(args) {
|
|
550
|
+
const [action, appId, slug, ...optionArgs] = args;
|
|
551
|
+
if (action === "list" && appId) {
|
|
552
|
+
const options = parseAccountOptions([slug, ...optionArgs].filter((value) => value !== undefined));
|
|
553
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/slugs`, {
|
|
554
|
+
method: "GET"
|
|
555
|
+
}, { accountId: options.account, accountScoped: true });
|
|
556
|
+
printRoutes(response.routes);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (action === "add" && appId && slug) {
|
|
560
|
+
const options = parseAccountOptions(optionArgs);
|
|
561
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/slugs`, {
|
|
562
|
+
method: "POST",
|
|
563
|
+
body: JSON.stringify({ slug })
|
|
564
|
+
}, { accountId: options.account, accountScoped: true });
|
|
565
|
+
printRoute(response.route);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (action === "remove" && appId && slug) {
|
|
569
|
+
const options = parseAccountOptions(optionArgs);
|
|
570
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/slugs/${encodeURIComponent(slug)}`, {
|
|
571
|
+
method: "DELETE"
|
|
572
|
+
}, { accountId: options.account, accountScoped: true });
|
|
573
|
+
printRoute(response.route);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
usage(1);
|
|
577
|
+
}
|
|
578
|
+
async function appDomainsCommand(args) {
|
|
579
|
+
const [action, appId, hostname, ...optionArgs] = args;
|
|
580
|
+
if (action === "list" && appId) {
|
|
581
|
+
const options = parseAccountOptions([hostname, ...optionArgs].filter((value) => value !== undefined));
|
|
582
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains`, {
|
|
583
|
+
method: "GET"
|
|
584
|
+
}, { accountId: options.account, accountScoped: true });
|
|
585
|
+
printRoutes(response.routes);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (action === "add" && appId && hostname) {
|
|
589
|
+
const options = parseAccountOptions(optionArgs);
|
|
590
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains`, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
body: JSON.stringify({ hostname })
|
|
593
|
+
}, { accountId: options.account, accountScoped: true });
|
|
594
|
+
printRoute(response.route);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (action === "verify" && appId && hostname) {
|
|
598
|
+
const options = parseAccountOptions(optionArgs);
|
|
599
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains/${encodeURIComponent(hostname)}/verify`, {
|
|
600
|
+
method: "POST",
|
|
601
|
+
body: JSON.stringify({})
|
|
602
|
+
}, { accountId: options.account, accountScoped: true });
|
|
603
|
+
printRoute(response.route);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (action === "remove" && appId && hostname) {
|
|
607
|
+
const options = parseAccountOptions(optionArgs);
|
|
608
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains/${encodeURIComponent(hostname)}`, {
|
|
609
|
+
method: "DELETE"
|
|
610
|
+
}, { accountId: options.account, accountScoped: true });
|
|
611
|
+
printRoute(response.route);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
usage(1);
|
|
615
|
+
}
|
|
616
|
+
async function opsFlagCommand(scope, id, flag, args, clear) {
|
|
617
|
+
const options = parseReasonOptions(args);
|
|
618
|
+
return await apiFetch(`/v0/ops/${scope}/${encodeURIComponent(id)}/flags/${encodeURIComponent(flag)}${clear ? "/clear" : ""}`, {
|
|
619
|
+
method: clear ? "POST" : "PUT",
|
|
620
|
+
body: JSON.stringify(bodyWithReason(options))
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
async function resolveAccountId(explicitAccountId) {
|
|
624
|
+
const credentials = await readCredentials();
|
|
625
|
+
const accountId = selectedAccountId(explicitAccountId, credentials);
|
|
626
|
+
if (accountId) {
|
|
627
|
+
return accountId;
|
|
628
|
+
}
|
|
629
|
+
const response = await apiFetch("/v0/accounts", { method: "GET" });
|
|
630
|
+
return response.default_account_id;
|
|
631
|
+
}
|
|
632
|
+
function printRoutes(routes) {
|
|
633
|
+
for (const route of routes) {
|
|
634
|
+
printRoute(route);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function printRoute(route) {
|
|
638
|
+
console.log([
|
|
639
|
+
route.route_id,
|
|
640
|
+
route.route_type,
|
|
641
|
+
route.status,
|
|
642
|
+
route.hostname,
|
|
643
|
+
route.slug ?? "",
|
|
644
|
+
route.reason ?? ""
|
|
645
|
+
].join("\t"));
|
|
646
|
+
if (route.verification && Object.keys(route.verification).length > 0) {
|
|
647
|
+
console.log(`verification=${JSON.stringify(route.verification)}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async function printObject(value) {
|
|
651
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
652
|
+
if (Array.isArray(entry)) {
|
|
653
|
+
for (const item of entry) {
|
|
654
|
+
console.log(`${key}=${isPlainObject(item) ? formatFields(item) : String(item)}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
else if (isPlainObject(entry)) {
|
|
658
|
+
console.log(`${key}=${JSON.stringify(entry)}`);
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
console.log(`${key}=${entry ?? ""}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function printKeyValues(prefix, values) {
|
|
666
|
+
for (const [key, value] of Object.entries(values).sort(([left], [right]) => left.localeCompare(right))) {
|
|
667
|
+
console.log(`${prefix}=${key} value=${value === null ? "unlimited" : formatFieldValue(value)}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function printList(label, values) {
|
|
671
|
+
if (values && values.length > 0) {
|
|
672
|
+
console.log(`${label}=${values.join(",")}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function printWarnings(warnings) {
|
|
676
|
+
for (const warning of warnings ?? []) {
|
|
677
|
+
console.log(`warning=${formatFields(warning)}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function printOptionalBoolean(label, value) {
|
|
681
|
+
if (typeof value === "boolean") {
|
|
682
|
+
console.log(`${label}=${value}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function bodyWithReason(options) {
|
|
686
|
+
return options.reason ? { reason: options.reason } : {};
|
|
687
|
+
}
|
|
309
688
|
async function readPublishDirectory(rootDir, options) {
|
|
310
689
|
const absoluteRoot = path.resolve(rootDir);
|
|
311
690
|
const stat = await fs.stat(absoluteRoot).catch(() => null);
|
|
@@ -434,20 +813,50 @@ async function apiFetch(apiPath, init, options = {}) {
|
|
|
434
813
|
headers
|
|
435
814
|
});
|
|
436
815
|
}
|
|
437
|
-
|
|
438
|
-
return
|
|
816
|
+
function selectedAccountId(explicitAccountId, credentials) {
|
|
817
|
+
return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
|
|
439
818
|
}
|
|
440
|
-
async function
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
819
|
+
async function pollDeviceAuthorization(baseUrl, start) {
|
|
820
|
+
let intervalSeconds = positiveNumber(start.interval, 5);
|
|
821
|
+
const deadline = Date.now() + positiveNumber(start.expires_in, 900) * 1000;
|
|
822
|
+
while (Date.now() <= deadline) {
|
|
823
|
+
const poll = await requestJson(baseUrl, "/v0/auth/device/poll", {
|
|
824
|
+
method: "POST",
|
|
825
|
+
body: JSON.stringify({ device_code: start.device_code })
|
|
826
|
+
});
|
|
827
|
+
if (poll.ok) {
|
|
828
|
+
return poll;
|
|
445
829
|
}
|
|
446
|
-
|
|
447
|
-
|
|
830
|
+
if (poll.status === "authorization_pending") {
|
|
831
|
+
await sleep(intervalSeconds * 1000);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (poll.status === "slow_down") {
|
|
835
|
+
intervalSeconds = positiveNumber(poll.interval, intervalSeconds + 5);
|
|
836
|
+
await sleep(intervalSeconds * 1000);
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
throw new Error(deviceAuthorizationStatusMessage(poll.status));
|
|
840
|
+
}
|
|
841
|
+
throw new Error("Device authorization expired before approval.");
|
|
448
842
|
}
|
|
449
|
-
function
|
|
450
|
-
|
|
843
|
+
function deviceAuthorizationStatusMessage(status) {
|
|
844
|
+
if (status === "denied") {
|
|
845
|
+
return "Device authorization was denied in the browser.";
|
|
846
|
+
}
|
|
847
|
+
if (status === "expired") {
|
|
848
|
+
return "Device authorization expired before approval.";
|
|
849
|
+
}
|
|
850
|
+
return "Device authorization was already consumed. Run `userland login` again.";
|
|
851
|
+
}
|
|
852
|
+
function positiveNumber(value, fallback) {
|
|
853
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
854
|
+
}
|
|
855
|
+
async function sleep(milliseconds) {
|
|
856
|
+
if (milliseconds <= 0) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
451
860
|
}
|
|
452
861
|
async function requestJson(baseUrl, apiPath, init) {
|
|
453
862
|
const response = await fetch(`${baseUrl.replace(/\/$/u, "")}${apiPath}`, {
|
|
@@ -465,8 +874,38 @@ async function requestJson(baseUrl, apiPath, init) {
|
|
|
465
874
|
}
|
|
466
875
|
return body;
|
|
467
876
|
}
|
|
468
|
-
async function apiBaseUrl(credentials) {
|
|
469
|
-
return process.env.USERLAND_API_BASE_URL ?? credentials?.api_base_url ?? (await readCredentials())?.api_base_url ?? DEFAULT_API_BASE_URL;
|
|
877
|
+
async function apiBaseUrl(credentials, override) {
|
|
878
|
+
return override ?? process.env.USERLAND_API_BASE_URL ?? credentials?.api_base_url ?? (await readCredentials())?.api_base_url ?? DEFAULT_API_BASE_URL;
|
|
879
|
+
}
|
|
880
|
+
async function consoleBaseUrl(credentials, override) {
|
|
881
|
+
return override ?? process.env.USERLAND_CONSOLE_URL ?? credentials?.console_url ?? (await readCredentials())?.console_url ?? DEFAULT_CONSOLE_BASE_URL;
|
|
882
|
+
}
|
|
883
|
+
function consoleUrlFromVerification(verificationUri, fallback) {
|
|
884
|
+
if (!verificationUri) {
|
|
885
|
+
return fallback;
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
const url = new URL(verificationUri);
|
|
889
|
+
return `${url.origin}`;
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
return fallback;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async function openBrowser(url) {
|
|
896
|
+
const [command, args] = process.platform === "darwin"
|
|
897
|
+
? ["open", [url]]
|
|
898
|
+
: process.platform === "win32"
|
|
899
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
900
|
+
: ["xdg-open", [url]];
|
|
901
|
+
return await new Promise((resolve) => {
|
|
902
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
903
|
+
child.once("error", () => resolve(false));
|
|
904
|
+
child.once("spawn", () => {
|
|
905
|
+
child.unref();
|
|
906
|
+
resolve(true);
|
|
907
|
+
});
|
|
908
|
+
});
|
|
470
909
|
}
|
|
471
910
|
function credentialsPath() {
|
|
472
911
|
return process.env.USERLAND_CREDENTIALS_FILE ?? path.join(os.homedir(), ".userland", "credentials.json");
|
|
@@ -491,7 +930,10 @@ async function readCredentials() {
|
|
|
491
930
|
account_id: stringValue(credentials.account_id),
|
|
492
931
|
api_base_url: stringValue(credentials.api_base_url),
|
|
493
932
|
api_key: stringValue(credentials.api_key),
|
|
494
|
-
|
|
933
|
+
api_key_id: stringValue(credentials.api_key_id),
|
|
934
|
+
console_url: stringValue(credentials.console_url),
|
|
935
|
+
updated_at: stringValue(credentials.updated_at),
|
|
936
|
+
username: stringValue(credentials.username)
|
|
495
937
|
};
|
|
496
938
|
}
|
|
497
939
|
async function saveCredentials(update) {
|
|
@@ -503,8 +945,10 @@ async function saveCredentials(update) {
|
|
|
503
945
|
...sanitizedUpdate,
|
|
504
946
|
updated_at: new Date().toISOString()
|
|
505
947
|
};
|
|
506
|
-
|
|
507
|
-
|
|
948
|
+
for (const [key, value] of Object.entries(update)) {
|
|
949
|
+
if (value === null) {
|
|
950
|
+
delete credentials[key];
|
|
951
|
+
}
|
|
508
952
|
}
|
|
509
953
|
const dir = path.dirname(filePath);
|
|
510
954
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
@@ -513,261 +957,6 @@ async function saveCredentials(update) {
|
|
|
513
957
|
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
514
958
|
return filePath;
|
|
515
959
|
}
|
|
516
|
-
async function readAccountCredentials() {
|
|
517
|
-
const raw = await keychainGetSecret().catch((error) => {
|
|
518
|
-
if (error instanceof KeychainUnavailableError) {
|
|
519
|
-
return undefined;
|
|
520
|
-
}
|
|
521
|
-
throw error;
|
|
522
|
-
});
|
|
523
|
-
if (!raw) {
|
|
524
|
-
return undefined;
|
|
525
|
-
}
|
|
526
|
-
const parsed = JSON.parse(raw);
|
|
527
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
528
|
-
throw new Error("Stored Userland account credentials are malformed.");
|
|
529
|
-
}
|
|
530
|
-
const credentials = parsed;
|
|
531
|
-
const username = stringValue(credentials.username);
|
|
532
|
-
const password = stringValue(credentials.password);
|
|
533
|
-
return username || password ? { username, password } : undefined;
|
|
534
|
-
}
|
|
535
|
-
async function saveAccountCredentials(update) {
|
|
536
|
-
const existing = (await readAccountCredentials()) ?? {};
|
|
537
|
-
const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
|
|
538
|
-
const credentials = {
|
|
539
|
-
...existing,
|
|
540
|
-
...sanitizedUpdate
|
|
541
|
-
};
|
|
542
|
-
if (!credentials.username && !credentials.password) {
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
await keychainSetSecret(JSON.stringify(credentials));
|
|
546
|
-
}
|
|
547
|
-
function accountCredentialStoreLabel() {
|
|
548
|
-
return process.env.USERLAND_KEYCHAIN_FILE ? "test keychain" : "OS keychain";
|
|
549
|
-
}
|
|
550
|
-
class KeychainUnavailableError extends Error {
|
|
551
|
-
}
|
|
552
|
-
async function keychainGetSecret() {
|
|
553
|
-
const testKeychainFile = process.env.USERLAND_KEYCHAIN_FILE;
|
|
554
|
-
if (testKeychainFile) {
|
|
555
|
-
return await fileKeychainGet(testKeychainFile);
|
|
556
|
-
}
|
|
557
|
-
if (process.platform === "darwin") {
|
|
558
|
-
const result = await runCommand("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"]);
|
|
559
|
-
if (result.code === 44) {
|
|
560
|
-
return undefined;
|
|
561
|
-
}
|
|
562
|
-
assertCommandOk("security", result);
|
|
563
|
-
return result.stdout.trimEnd();
|
|
564
|
-
}
|
|
565
|
-
if (process.platform === "linux") {
|
|
566
|
-
const result = await runCommand("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT]);
|
|
567
|
-
if (result.code === 1) {
|
|
568
|
-
return undefined;
|
|
569
|
-
}
|
|
570
|
-
assertCommandOk("secret-tool", result);
|
|
571
|
-
return result.stdout.trimEnd();
|
|
572
|
-
}
|
|
573
|
-
if (process.platform === "win32") {
|
|
574
|
-
const result = await runPowerShell(windowsCredentialReadScript());
|
|
575
|
-
if (result.code === 2) {
|
|
576
|
-
return undefined;
|
|
577
|
-
}
|
|
578
|
-
assertCommandOk("powershell", result);
|
|
579
|
-
return result.stdout.trimEnd();
|
|
580
|
-
}
|
|
581
|
-
throw new KeychainUnavailableError(`OS keychain is not supported on ${process.platform}.`);
|
|
582
|
-
}
|
|
583
|
-
async function keychainSetSecret(secret) {
|
|
584
|
-
const testKeychainFile = process.env.USERLAND_KEYCHAIN_FILE;
|
|
585
|
-
if (testKeychainFile) {
|
|
586
|
-
await fileKeychainSet(testKeychainFile, secret);
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
if (process.platform === "darwin") {
|
|
590
|
-
assertCommandOk("security", await runCommand("security", ["add-generic-password", "-U", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w", secret]));
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
if (process.platform === "linux") {
|
|
594
|
-
assertCommandOk("secret-tool", await runCommand("secret-tool", ["store", "--label", "Userland CLI", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT], secret));
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
if (process.platform === "win32") {
|
|
598
|
-
assertCommandOk("powershell", await runPowerShell(windowsCredentialWriteScript(), secret));
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
throw new KeychainUnavailableError(`OS keychain is not supported on ${process.platform}.`);
|
|
602
|
-
}
|
|
603
|
-
async function fileKeychainGet(filePath) {
|
|
604
|
-
const contents = await fs.readFile(filePath, "utf8").catch((error) => {
|
|
605
|
-
if (error.code === "ENOENT") {
|
|
606
|
-
return undefined;
|
|
607
|
-
}
|
|
608
|
-
throw error;
|
|
609
|
-
});
|
|
610
|
-
if (!contents) {
|
|
611
|
-
return undefined;
|
|
612
|
-
}
|
|
613
|
-
const parsed = JSON.parse(contents);
|
|
614
|
-
return parsed[`${KEYCHAIN_SERVICE}:${KEYCHAIN_ACCOUNT}`];
|
|
615
|
-
}
|
|
616
|
-
async function fileKeychainSet(filePath, secret) {
|
|
617
|
-
const existing = await fs.readFile(filePath, "utf8").catch((error) => {
|
|
618
|
-
if (error.code === "ENOENT") {
|
|
619
|
-
return "{}";
|
|
620
|
-
}
|
|
621
|
-
throw error;
|
|
622
|
-
});
|
|
623
|
-
const parsed = JSON.parse(existing);
|
|
624
|
-
parsed[`${KEYCHAIN_SERVICE}:${KEYCHAIN_ACCOUNT}`] = secret;
|
|
625
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
626
|
-
await fs.writeFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
627
|
-
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
628
|
-
}
|
|
629
|
-
async function runCommand(command, args, stdin, env) {
|
|
630
|
-
return await new Promise((resolve, reject) => {
|
|
631
|
-
const child = spawn(command, args, { env: env ? { ...process.env, ...env } : process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
632
|
-
const stdout = [];
|
|
633
|
-
const stderr = [];
|
|
634
|
-
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
635
|
-
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
636
|
-
child.on("error", (error) => {
|
|
637
|
-
if (error.code === "ENOENT") {
|
|
638
|
-
reject(new KeychainUnavailableError(`${command} is required for OS keychain access.`));
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
reject(error);
|
|
642
|
-
});
|
|
643
|
-
child.on("close", (code) => {
|
|
644
|
-
resolve({
|
|
645
|
-
code,
|
|
646
|
-
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
647
|
-
stderr: Buffer.concat(stderr).toString("utf8")
|
|
648
|
-
});
|
|
649
|
-
});
|
|
650
|
-
child.stdin.end(stdin ?? "");
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
async function runPowerShell(script, stdin) {
|
|
654
|
-
const env = stdin === undefined ? undefined : { USERLAND_KEYCHAIN_SECRET: stdin };
|
|
655
|
-
return await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "-"], scriptWithInput(script), env);
|
|
656
|
-
}
|
|
657
|
-
function scriptWithInput(script) {
|
|
658
|
-
return `$ErrorActionPreference = "Stop"\n${script}`;
|
|
659
|
-
}
|
|
660
|
-
function assertCommandOk(command, result) {
|
|
661
|
-
if (result.code !== 0) {
|
|
662
|
-
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code ?? "unknown"}`;
|
|
663
|
-
throw new Error(`${command} failed while accessing the OS keychain: ${detail}`);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
function windowsCredentialWriteScript() {
|
|
667
|
-
return `
|
|
668
|
-
Add-Type -TypeDefinition @"
|
|
669
|
-
using System;
|
|
670
|
-
using System.ComponentModel;
|
|
671
|
-
using System.Runtime.InteropServices;
|
|
672
|
-
using System.Text;
|
|
673
|
-
|
|
674
|
-
public static class UserlandCredential {
|
|
675
|
-
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
676
|
-
private struct Credential {
|
|
677
|
-
public UInt32 Flags;
|
|
678
|
-
public UInt32 Type;
|
|
679
|
-
public string TargetName;
|
|
680
|
-
public string Comment;
|
|
681
|
-
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
682
|
-
public UInt32 CredentialBlobSize;
|
|
683
|
-
public IntPtr CredentialBlob;
|
|
684
|
-
public UInt32 Persist;
|
|
685
|
-
public UInt32 AttributeCount;
|
|
686
|
-
public IntPtr Attributes;
|
|
687
|
-
public string TargetAlias;
|
|
688
|
-
public string UserName;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
[DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
692
|
-
private static extern bool CredWrite(ref Credential credential, UInt32 flags);
|
|
693
|
-
|
|
694
|
-
public static void Write(string target, string username, string secret) {
|
|
695
|
-
byte[] bytes = Encoding.Unicode.GetBytes(secret);
|
|
696
|
-
IntPtr blob = Marshal.AllocCoTaskMem(bytes.Length);
|
|
697
|
-
try {
|
|
698
|
-
Marshal.Copy(bytes, 0, blob, bytes.Length);
|
|
699
|
-
Credential credential = new Credential();
|
|
700
|
-
credential.Type = 1;
|
|
701
|
-
credential.TargetName = target;
|
|
702
|
-
credential.UserName = username;
|
|
703
|
-
credential.CredentialBlob = blob;
|
|
704
|
-
credential.CredentialBlobSize = (UInt32)bytes.Length;
|
|
705
|
-
credential.Persist = 2;
|
|
706
|
-
if (!CredWrite(ref credential, 0)) {
|
|
707
|
-
throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
708
|
-
}
|
|
709
|
-
} finally {
|
|
710
|
-
Marshal.FreeCoTaskMem(blob);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
"@
|
|
715
|
-
$secret = [Environment]::GetEnvironmentVariable("USERLAND_KEYCHAIN_SECRET")
|
|
716
|
-
[UserlandCredential]::Write(${JSON.stringify(KEYCHAIN_SERVICE)}, ${JSON.stringify(KEYCHAIN_ACCOUNT)}, $secret)
|
|
717
|
-
`;
|
|
718
|
-
}
|
|
719
|
-
function windowsCredentialReadScript() {
|
|
720
|
-
return `
|
|
721
|
-
Add-Type -TypeDefinition @"
|
|
722
|
-
using System;
|
|
723
|
-
using System.ComponentModel;
|
|
724
|
-
using System.Runtime.InteropServices;
|
|
725
|
-
|
|
726
|
-
public static class UserlandCredential {
|
|
727
|
-
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
728
|
-
private struct Credential {
|
|
729
|
-
public UInt32 Flags;
|
|
730
|
-
public UInt32 Type;
|
|
731
|
-
public string TargetName;
|
|
732
|
-
public string Comment;
|
|
733
|
-
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
734
|
-
public UInt32 CredentialBlobSize;
|
|
735
|
-
public IntPtr CredentialBlob;
|
|
736
|
-
public UInt32 Persist;
|
|
737
|
-
public UInt32 AttributeCount;
|
|
738
|
-
public IntPtr Attributes;
|
|
739
|
-
public string TargetAlias;
|
|
740
|
-
public string UserName;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
[DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
744
|
-
private static extern bool CredRead(string target, UInt32 type, UInt32 reservedFlag, out IntPtr credentialPtr);
|
|
745
|
-
|
|
746
|
-
[DllImport("Advapi32.dll", SetLastError = true)]
|
|
747
|
-
private static extern void CredFree(IntPtr buffer);
|
|
748
|
-
|
|
749
|
-
public static string Read(string target) {
|
|
750
|
-
IntPtr credentialPtr;
|
|
751
|
-
if (!CredRead(target, 1, 0, out credentialPtr)) {
|
|
752
|
-
int error = Marshal.GetLastWin32Error();
|
|
753
|
-
if (error == 1168) {
|
|
754
|
-
Environment.Exit(2);
|
|
755
|
-
}
|
|
756
|
-
throw new Win32Exception(error);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
try {
|
|
760
|
-
Credential credential = (Credential)Marshal.PtrToStructure(credentialPtr, typeof(Credential));
|
|
761
|
-
return Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
|
|
762
|
-
} finally {
|
|
763
|
-
CredFree(credentialPtr);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
"@
|
|
768
|
-
[Console]::Out.Write([UserlandCredential]::Read(${JSON.stringify(KEYCHAIN_SERVICE)}))
|
|
769
|
-
`;
|
|
770
|
-
}
|
|
771
960
|
function parseOptions(args) {
|
|
772
961
|
const options = {};
|
|
773
962
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -791,11 +980,8 @@ function parseAuthOptions(args) {
|
|
|
791
980
|
const options = { save: true };
|
|
792
981
|
for (let index = 0; index < args.length; index += 1) {
|
|
793
982
|
const arg = args[index];
|
|
794
|
-
if (arg === "--username") {
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
else if (arg === "--password") {
|
|
798
|
-
options.password = args[++index];
|
|
983
|
+
if (arg === "--username" || arg === "--password") {
|
|
984
|
+
throw new Error("Userland platform auth is passwordless. Run `userland login` without username/password flags.");
|
|
799
985
|
}
|
|
800
986
|
else if (arg === "--email") {
|
|
801
987
|
options.email = args[++index];
|
|
@@ -806,6 +992,24 @@ function parseAuthOptions(args) {
|
|
|
806
992
|
else if (arg === "--no-save") {
|
|
807
993
|
options.save = false;
|
|
808
994
|
}
|
|
995
|
+
else if (arg === "--save=false") {
|
|
996
|
+
options.save = false;
|
|
997
|
+
}
|
|
998
|
+
else if (arg === "--no-browser") {
|
|
999
|
+
options.noBrowser = true;
|
|
1000
|
+
}
|
|
1001
|
+
else if (arg === "--api-base-url") {
|
|
1002
|
+
options.apiBaseUrl = args[++index];
|
|
1003
|
+
}
|
|
1004
|
+
else if (arg === "--console-url") {
|
|
1005
|
+
options.consoleUrl = args[++index];
|
|
1006
|
+
}
|
|
1007
|
+
else if (arg === "--account") {
|
|
1008
|
+
options.account = args[++index];
|
|
1009
|
+
}
|
|
1010
|
+
else if (arg === "--revoke") {
|
|
1011
|
+
options.revoke = true;
|
|
1012
|
+
}
|
|
809
1013
|
else {
|
|
810
1014
|
throw new Error(`Unknown option: ${arg}`);
|
|
811
1015
|
}
|
|
@@ -853,6 +1057,54 @@ function parseEventsOptions(args) {
|
|
|
853
1057
|
}
|
|
854
1058
|
return options;
|
|
855
1059
|
}
|
|
1060
|
+
function parseDowngradePreviewOptions(args) {
|
|
1061
|
+
const options = {};
|
|
1062
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1063
|
+
const arg = args[index];
|
|
1064
|
+
if (arg === "--to") {
|
|
1065
|
+
options.to = args[++index];
|
|
1066
|
+
}
|
|
1067
|
+
else if (arg === "--account") {
|
|
1068
|
+
options.account = args[++index];
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return options;
|
|
1075
|
+
}
|
|
1076
|
+
function parseReasonOptions(args) {
|
|
1077
|
+
const options = {};
|
|
1078
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1079
|
+
const arg = args[index];
|
|
1080
|
+
if (arg === "--reason") {
|
|
1081
|
+
options.reason = args[++index];
|
|
1082
|
+
}
|
|
1083
|
+
else if (arg === "--account") {
|
|
1084
|
+
options.account = args[++index];
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return options;
|
|
1091
|
+
}
|
|
1092
|
+
function parseRouteDisableOptions(args) {
|
|
1093
|
+
const options = {};
|
|
1094
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1095
|
+
const arg = args[index];
|
|
1096
|
+
if (arg === "--status") {
|
|
1097
|
+
options.status = args[++index];
|
|
1098
|
+
}
|
|
1099
|
+
else if (arg === "--reason") {
|
|
1100
|
+
options.reason = args[++index];
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return options;
|
|
1107
|
+
}
|
|
856
1108
|
function parseAccountOptions(args) {
|
|
857
1109
|
const options = {};
|
|
858
1110
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -892,54 +1144,33 @@ async function promptLine(prompt) {
|
|
|
892
1144
|
readline.close();
|
|
893
1145
|
}
|
|
894
1146
|
}
|
|
895
|
-
async function promptPassword(prompt) {
|
|
896
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
897
|
-
return await promptRequired(prompt);
|
|
898
|
-
}
|
|
899
|
-
process.stdout.write(prompt);
|
|
900
|
-
process.stdin.setRawMode(true);
|
|
901
|
-
process.stdin.resume();
|
|
902
|
-
process.stdin.setEncoding("utf8");
|
|
903
|
-
return await new Promise((resolve, reject) => {
|
|
904
|
-
let value = "";
|
|
905
|
-
const cleanup = () => {
|
|
906
|
-
process.stdin.setRawMode(false);
|
|
907
|
-
process.stdin.off("data", onData);
|
|
908
|
-
};
|
|
909
|
-
const onData = (chunk) => {
|
|
910
|
-
for (const char of chunk) {
|
|
911
|
-
if (char === "\u0003") {
|
|
912
|
-
cleanup();
|
|
913
|
-
process.stdout.write("\n");
|
|
914
|
-
reject(new Error("Interrupted."));
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
if (char === "\r" || char === "\n" || char === "\u0004") {
|
|
918
|
-
cleanup();
|
|
919
|
-
process.stdout.write("\n");
|
|
920
|
-
if (!value) {
|
|
921
|
-
reject(new Error("Password is required."));
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
resolve(value);
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
if (char === "\u007f") {
|
|
928
|
-
value = value.slice(0, -1);
|
|
929
|
-
continue;
|
|
930
|
-
}
|
|
931
|
-
value += char;
|
|
932
|
-
}
|
|
933
|
-
};
|
|
934
|
-
process.stdin.on("data", onData);
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
1147
|
function objectValue(value) {
|
|
938
1148
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
939
1149
|
}
|
|
940
1150
|
function stringValue(value) {
|
|
941
1151
|
return typeof value === "string" ? value : undefined;
|
|
942
1152
|
}
|
|
1153
|
+
function stringArrayValue(value) {
|
|
1154
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : undefined;
|
|
1155
|
+
}
|
|
1156
|
+
function formatFields(value) {
|
|
1157
|
+
return Object.entries(value)
|
|
1158
|
+
.filter(([, entry]) => entry !== undefined && entry !== null)
|
|
1159
|
+
.map(([key, entry]) => `${key}=${formatFieldValue(entry)}`)
|
|
1160
|
+
.join(" ");
|
|
1161
|
+
}
|
|
1162
|
+
function formatFieldValue(value) {
|
|
1163
|
+
if (typeof value === "string") {
|
|
1164
|
+
return /\s/u.test(value) ? JSON.stringify(value) : value;
|
|
1165
|
+
}
|
|
1166
|
+
if (Array.isArray(value)) {
|
|
1167
|
+
return value.map(formatFieldValue).join(",");
|
|
1168
|
+
}
|
|
1169
|
+
if (isPlainObject(value)) {
|
|
1170
|
+
return JSON.stringify(value);
|
|
1171
|
+
}
|
|
1172
|
+
return String(value);
|
|
1173
|
+
}
|
|
943
1174
|
function contentTypeForPath(filePath) {
|
|
944
1175
|
const ext = path.extname(filePath).toLowerCase();
|
|
945
1176
|
const types = {
|
|
@@ -958,46 +1189,57 @@ function contentTypeForPath(filePath) {
|
|
|
958
1189
|
return types[ext] ?? "application/octet-stream";
|
|
959
1190
|
}
|
|
960
1191
|
function errorMessage(body) {
|
|
961
|
-
if (
|
|
1192
|
+
if (!isPlainObject(body) || !("error" in body)) {
|
|
962
1193
|
return undefined;
|
|
963
1194
|
}
|
|
964
|
-
const
|
|
965
|
-
if (
|
|
1195
|
+
const parsed = parseApiError(body);
|
|
1196
|
+
if (!parsed.message && !parsed.code) {
|
|
966
1197
|
return undefined;
|
|
967
1198
|
}
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
return
|
|
1199
|
+
const lines = [parsed.message ?? parsed.code];
|
|
1200
|
+
if (parsed.code) {
|
|
1201
|
+
lines.push(`error=${parsed.code}`);
|
|
1202
|
+
}
|
|
1203
|
+
lines.push(...structuredDetailLines(parsed.details));
|
|
1204
|
+
return lines.filter(Boolean).join("\n");
|
|
1205
|
+
}
|
|
1206
|
+
function parseApiError(body) {
|
|
1207
|
+
const error = body.error;
|
|
1208
|
+
if (typeof error === "string") {
|
|
1209
|
+
return {
|
|
1210
|
+
code: error,
|
|
1211
|
+
message: stringValue(body.message),
|
|
1212
|
+
details: body.details
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
if (!isPlainObject(error)) {
|
|
1216
|
+
return {};
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
code: stringValue(error.code),
|
|
1220
|
+
message: stringValue(error.message),
|
|
1221
|
+
details: error.details
|
|
1222
|
+
};
|
|
974
1223
|
}
|
|
975
|
-
function
|
|
1224
|
+
function structuredDetailLines(details) {
|
|
976
1225
|
if (!isPlainObject(details)) {
|
|
977
|
-
return
|
|
1226
|
+
return [];
|
|
978
1227
|
}
|
|
979
|
-
const requiredPlan = stringValue(details.required_plan_key);
|
|
980
|
-
const violations = Array.isArray(details.violations) ? details.violations.map(formatEntitlementViolation).filter(Boolean) : [];
|
|
981
|
-
const limit = stringValue(details.limit_key);
|
|
982
|
-
const allowed = details.allowed;
|
|
983
|
-
const value = details.value;
|
|
984
1228
|
const lines = [];
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
lines.push("Violations:");
|
|
990
|
-
lines.push(...violations.map((violation) => `- ${violation}`));
|
|
1229
|
+
for (const key of ["metric", "plan_key", "required_plan_key", "limit", "limit_key", "current", "increment", "value", "upgrade_required"]) {
|
|
1230
|
+
if (details[key] !== undefined) {
|
|
1231
|
+
lines.push(`${key}=${formatFieldValue(details[key])}`);
|
|
1232
|
+
}
|
|
991
1233
|
}
|
|
992
|
-
|
|
993
|
-
|
|
1234
|
+
const violations = Array.isArray(details.violations) ? details.violations : [];
|
|
1235
|
+
for (const violation of violations) {
|
|
1236
|
+
if (isPlainObject(violation)) {
|
|
1237
|
+
lines.push(`violation=${formatViolation(violation)}`);
|
|
1238
|
+
}
|
|
994
1239
|
}
|
|
995
|
-
return lines
|
|
1240
|
+
return lines;
|
|
996
1241
|
}
|
|
997
|
-
function
|
|
998
|
-
if (!isPlainObject(value)) {
|
|
999
|
-
return undefined;
|
|
1000
|
-
}
|
|
1242
|
+
function formatViolation(value) {
|
|
1001
1243
|
const path = stringValue(value.manifest_path);
|
|
1002
1244
|
const feature = stringValue(value.feature_key);
|
|
1003
1245
|
const limit = stringValue(value.limit_key);
|
|
@@ -1012,7 +1254,7 @@ function formatEntitlementViolation(value) {
|
|
|
1012
1254
|
allowed !== undefined ? `allowed=${formatAllowed(allowed)}` : undefined,
|
|
1013
1255
|
requiredPlan ? `requires=${requiredPlan}` : undefined
|
|
1014
1256
|
].filter(Boolean);
|
|
1015
|
-
return parts.length > 0 ? parts.join(" ") :
|
|
1257
|
+
return parts.length > 0 ? parts.join(" ") : formatFields(value);
|
|
1016
1258
|
}
|
|
1017
1259
|
function formatAllowed(value) {
|
|
1018
1260
|
return Array.isArray(value) ? value.join(",") : String(value);
|
|
@@ -1021,7 +1263,7 @@ function isPlainObject(value) {
|
|
|
1021
1263
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1022
1264
|
}
|
|
1023
1265
|
function docsUrlForError(message) {
|
|
1024
|
-
if (message.includes("entitlement_required") || message.includes("plan_limit_exceeded")) {
|
|
1266
|
+
if (message.includes("entitlement_required") || message.includes("plan_limit_exceeded") || message.includes("quota_exceeded") || message.includes("downgrade_incompatible")) {
|
|
1025
1267
|
return "https://docs.userland.fun/reference/errors";
|
|
1026
1268
|
}
|
|
1027
1269
|
if (message.includes("USERLAND_API_KEY") || message.includes("credentials")) {
|
|
@@ -1041,29 +1283,52 @@ function isHelpCommand(command) {
|
|
|
1041
1283
|
function usage(exitCode) {
|
|
1042
1284
|
const message = `Usage:
|
|
1043
1285
|
userland [--help]
|
|
1044
|
-
userland signup [--
|
|
1045
|
-
userland login [--
|
|
1286
|
+
userland signup [--no-browser] [--email <email>] [--api-base-url <url>] [--console-url <url>] [--no-save]
|
|
1287
|
+
userland login [--no-browser] [--email <email>] [--api-base-url <url>] [--console-url <url>] [--no-save]
|
|
1046
1288
|
userland auth status
|
|
1047
|
-
userland auth save-key --
|
|
1289
|
+
userland auth save-key --api-key <api-key> [--account <account-id>] [--api-base-url <url>] [--console-url <url>]
|
|
1290
|
+
userland auth logout [--revoke]
|
|
1048
1291
|
userland accounts list
|
|
1049
1292
|
userland accounts use <account-id>
|
|
1293
|
+
userland accounts status [--account <account-id>]
|
|
1294
|
+
userland accounts limits [--account <account-id>]
|
|
1295
|
+
userland accounts downgrade preview --to <plan> [--account <account-id>]
|
|
1050
1296
|
userland apps publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
|
|
1051
1297
|
userland apps list [--account <account-id>]
|
|
1298
|
+
userland apps status <app-id> [--account <account-id>]
|
|
1052
1299
|
userland apps releases <app-id> [--account <account-id>]
|
|
1053
1300
|
userland apps rollback <app-id> <release-id> [--account <account-id>]
|
|
1054
1301
|
userland apps secrets set <app-id> <NAME> [--value <value>] [--account <account-id>]
|
|
1055
1302
|
userland apps events <app-id> [--type <event-type>] [--severity <level>] [--release <release-id>] [--limit <n>] [--account <account-id>]
|
|
1303
|
+
userland apps routes list <app-id> [--account <account-id>]
|
|
1304
|
+
userland apps slugs list <app-id> [--account <account-id>]
|
|
1305
|
+
userland apps slugs add <app-id> <slug> [--account <account-id>]
|
|
1306
|
+
userland apps slugs remove <app-id> <slug> [--account <account-id>]
|
|
1307
|
+
userland apps domains list <app-id> [--account <account-id>]
|
|
1308
|
+
userland apps domains add <app-id> <hostname> [--account <account-id>]
|
|
1309
|
+
userland apps domains verify <app-id> <hostname> [--account <account-id>]
|
|
1310
|
+
userland apps domains remove <app-id> <hostname> [--account <account-id>]
|
|
1311
|
+
userland ops accounts status <account-id>
|
|
1312
|
+
userland ops accounts flag <account-id> <flag> [--reason <text>]
|
|
1313
|
+
userland ops accounts clear <account-id> <flag> [--reason <text>]
|
|
1314
|
+
userland ops apps status <app-id>
|
|
1315
|
+
userland ops apps flag <app-id> <flag> [--reason <text>]
|
|
1316
|
+
userland ops apps clear <app-id> <flag> [--reason <text>]
|
|
1317
|
+
userland ops apps takedown <app-id> [--reason <text>]
|
|
1318
|
+
userland ops routes disable <route-id> --status <disabled_abuse|disabled_billing|disabled_downgrade> [--reason <text>]
|
|
1319
|
+
userland ops routes enable <route-id> [--reason <text>]
|
|
1056
1320
|
|
|
1057
1321
|
Aliases:
|
|
1058
|
-
userland auth signup [--
|
|
1059
|
-
userland auth login [--
|
|
1322
|
+
userland auth signup [--no-browser] [--email <email>] [--no-save]
|
|
1323
|
+
userland auth login [--no-browser] [--email <email>] [--no-save]
|
|
1060
1324
|
userland publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
|
|
1061
1325
|
userland releases <app-id> [--account <account-id>]
|
|
1326
|
+
userland versions <app-id> [--account <account-id>]
|
|
1062
1327
|
|
|
1063
1328
|
Credentials:
|
|
1064
1329
|
Commands use USERLAND_API_KEY first, then ~/.userland/credentials.json for API keys.
|
|
1065
1330
|
App commands use --account, then USERLAND_ACCOUNT_ID, then saved account_id when set.
|
|
1066
|
-
|
|
1331
|
+
Login and signup use browser device authorization and save only API-key credentials locally.
|
|
1067
1332
|
|
|
1068
1333
|
Docs:
|
|
1069
1334
|
https://docs.userland.fun/reference/cli
|