@userland.fun/cli 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/dist/index.js +462 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,14 +23,23 @@ userland auth status
|
|
|
23
23
|
userland auth save-key --username <username> --api-key <api-key>
|
|
24
24
|
userland accounts list
|
|
25
25
|
userland accounts use <account-id>
|
|
26
|
+
userland accounts status --account <account-id>
|
|
27
|
+
userland accounts limits --account <account-id>
|
|
28
|
+
userland accounts downgrade preview --to free --account <account-id>
|
|
26
29
|
userland apps publish examples/<example-slug>
|
|
27
30
|
userland apps publish examples/<example-slug> --account <account-id>
|
|
28
31
|
userland apps list
|
|
29
32
|
USERLAND_ACCOUNT_ID=<account-id> userland apps list
|
|
33
|
+
userland apps status <app-id>
|
|
30
34
|
userland apps releases <app-id>
|
|
35
|
+
userland versions <app-id>
|
|
31
36
|
userland apps rollback <app-id> <release-id>
|
|
32
37
|
userland apps secrets set <app-id> <NAME> --value <value>
|
|
33
38
|
userland apps events <app-id>
|
|
39
|
+
userland apps routes list <app-id>
|
|
40
|
+
userland apps slugs add <app-id> <slug>
|
|
41
|
+
userland apps domains add <app-id> <hostname>
|
|
42
|
+
userland apps domains verify <app-id> <hostname>
|
|
34
43
|
```
|
|
35
44
|
|
|
36
45
|
From this repo, the same commands can be run from source:
|
|
@@ -42,20 +51,79 @@ npm run userland -- auth status
|
|
|
42
51
|
npm run userland -- auth save-key --username <username> --api-key <api-key>
|
|
43
52
|
npm run userland -- accounts list
|
|
44
53
|
npm run userland -- accounts use <account-id>
|
|
54
|
+
npm run userland -- accounts status --account <account-id>
|
|
55
|
+
npm run userland -- accounts limits --account <account-id>
|
|
56
|
+
npm run userland -- accounts downgrade preview --to free --account <account-id>
|
|
45
57
|
npm run userland -- apps publish examples/<example-slug>
|
|
46
58
|
npm run userland -- apps publish examples/<example-slug> --account <account-id>
|
|
47
59
|
npm run userland -- apps list
|
|
48
60
|
npm run userland -- apps list --account <account-id>
|
|
61
|
+
npm run userland -- apps status <app-id>
|
|
49
62
|
npm run userland -- apps releases <app-id>
|
|
63
|
+
npm run userland -- versions <app-id>
|
|
50
64
|
npm run userland -- apps rollback <app-id> <release-id>
|
|
51
65
|
npm run userland -- apps secrets set <app-id> <NAME> --value <value>
|
|
52
66
|
npm run userland -- apps events <app-id>
|
|
67
|
+
npm run userland -- apps routes list <app-id>
|
|
68
|
+
npm run userland -- apps slugs add <app-id> <slug>
|
|
69
|
+
npm run userland -- apps domains add <app-id> <hostname>
|
|
70
|
+
npm run userland -- apps domains verify <app-id> <hostname>
|
|
53
71
|
```
|
|
54
72
|
|
|
55
73
|
`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.
|
|
56
74
|
|
|
57
75
|
Most users do not need to select an account. If no account is selected, the API uses the actor's default account. Team, client, and agency workflows can select an account with `--account <account-id>`, `USERLAND_ACCOUNT_ID`, or `userland accounts use <account-id>`. Platform account members manage apps, releases, secrets, billing, and settings; they are separate from app users inside a published app.
|
|
58
76
|
|
|
77
|
+
Status and limits:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
userland accounts status --account <account-id>
|
|
81
|
+
userland accounts limits --account <account-id>
|
|
82
|
+
userland accounts downgrade preview --to starter --account <account-id>
|
|
83
|
+
userland apps status <app-id> --account <account-id>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`accounts limits` includes plan features, manifest limits, deployment limits, runtime limits, release limits, usage limits, current usage, and route counts.
|
|
87
|
+
|
|
88
|
+
Route management:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
userland apps routes list <app-id> --account <account-id>
|
|
92
|
+
userland apps slugs list <app-id> --account <account-id>
|
|
93
|
+
userland apps slugs add <app-id> <slug> --account <account-id>
|
|
94
|
+
userland apps slugs remove <app-id> <slug> --account <account-id>
|
|
95
|
+
userland apps domains list <app-id> --account <account-id>
|
|
96
|
+
userland apps domains add <app-id> <hostname> --account <account-id>
|
|
97
|
+
userland apps domains verify <app-id> <hostname> --account <account-id>
|
|
98
|
+
userland apps domains remove <app-id> <hostname> --account <account-id>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Internal/platform-admin only operations are routed by the CLI but authorized server-side:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
userland ops accounts status <account-id>
|
|
105
|
+
userland ops accounts flag <account-id> suspended_abuse --reason "spam"
|
|
106
|
+
userland ops accounts clear <account-id> suspended_abuse --reason "reviewed"
|
|
107
|
+
userland ops apps status <app-id>
|
|
108
|
+
userland ops apps flag <app-id> suspended_security --reason "incident"
|
|
109
|
+
userland ops apps clear <app-id> suspended_security --reason "resolved"
|
|
110
|
+
userland ops apps takedown <app-id> --reason "legal review"
|
|
111
|
+
userland ops routes disable <route-id> --status disabled_abuse --reason "abuse"
|
|
112
|
+
userland ops routes enable <route-id> --reason "resolved"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Structured API errors keep details on separate lines:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
API 402: Monthly request quota exceeded for the current plan.
|
|
119
|
+
error=quota_exceeded
|
|
120
|
+
metric=requests.monthly.max
|
|
121
|
+
plan_key=free
|
|
122
|
+
limit=10000
|
|
123
|
+
current=10000
|
|
124
|
+
upgrade_required=true
|
|
125
|
+
```
|
|
126
|
+
|
|
59
127
|
## Validation
|
|
60
128
|
|
|
61
129
|
Build and inspect the publish tarball:
|
package/dist/index.js
CHANGED
|
@@ -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) {
|
|
@@ -100,6 +120,73 @@ async function accountsCommand(args) {
|
|
|
100
120
|
await useAccountCommand(rest);
|
|
101
121
|
return;
|
|
102
122
|
}
|
|
123
|
+
if (subcommand === "status") {
|
|
124
|
+
await accountStatusCommand(rest);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (subcommand === "limits") {
|
|
128
|
+
await accountLimitsCommand(rest);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (subcommand === "downgrade" && rest[0] === "preview") {
|
|
132
|
+
await downgradePreviewCommand(rest.slice(1));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
usage(1);
|
|
136
|
+
}
|
|
137
|
+
async function opsCommand(args) {
|
|
138
|
+
const [scope, subcommand, id, actionOrFlag, ...rest] = args;
|
|
139
|
+
if (scope === "accounts" && subcommand === "status" && id) {
|
|
140
|
+
await printObject(await apiFetch(`/v0/ops/accounts/${encodeURIComponent(id)}/status`, { method: "GET" }));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (scope === "accounts" && subcommand === "flag" && id && actionOrFlag) {
|
|
144
|
+
await printObject(await opsFlagCommand("accounts", id, actionOrFlag, rest, false));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (scope === "accounts" && subcommand === "clear" && id && actionOrFlag) {
|
|
148
|
+
await printObject(await opsFlagCommand("accounts", id, actionOrFlag, rest, true));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (scope === "apps" && subcommand === "status" && id) {
|
|
152
|
+
await printObject(await apiFetch(`/v0/ops/apps/${encodeURIComponent(id)}/status`, { method: "GET" }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (scope === "apps" && subcommand === "flag" && id && actionOrFlag) {
|
|
156
|
+
await printObject(await opsFlagCommand("apps", id, actionOrFlag, rest, false));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (scope === "apps" && subcommand === "clear" && id && actionOrFlag) {
|
|
160
|
+
await printObject(await opsFlagCommand("apps", id, actionOrFlag, rest, true));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (scope === "apps" && subcommand === "takedown" && id) {
|
|
164
|
+
const options = parseReasonOptions([actionOrFlag, ...rest].filter((value) => value !== undefined));
|
|
165
|
+
await printObject(await apiFetch(`/v0/ops/apps/${encodeURIComponent(id)}/takedown`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: JSON.stringify(bodyWithReason(options))
|
|
168
|
+
}));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (scope === "routes" && subcommand === "disable" && id) {
|
|
172
|
+
const options = parseRouteDisableOptions([actionOrFlag, ...rest].filter((value) => value !== undefined));
|
|
173
|
+
if (!options.status) {
|
|
174
|
+
usage(1);
|
|
175
|
+
}
|
|
176
|
+
await printObject(await apiFetch(`/v0/ops/routes/${encodeURIComponent(id)}/disable`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: JSON.stringify({ status: options.status, ...bodyWithReason(options) })
|
|
179
|
+
}));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (scope === "routes" && subcommand === "enable" && id) {
|
|
183
|
+
const options = parseReasonOptions([actionOrFlag, ...rest].filter((value) => value !== undefined));
|
|
184
|
+
await printObject(await apiFetch(`/v0/ops/routes/${encodeURIComponent(id)}/enable`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
body: JSON.stringify(bodyWithReason(options))
|
|
187
|
+
}));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
103
190
|
usage(1);
|
|
104
191
|
}
|
|
105
192
|
async function signupCommand(args) {
|
|
@@ -203,6 +290,69 @@ async function useAccountCommand(args) {
|
|
|
203
290
|
console.log(`selected_account_id=${accountId}`);
|
|
204
291
|
console.log(`credentials_file=${filePath}`);
|
|
205
292
|
}
|
|
293
|
+
async function accountStatusCommand(args) {
|
|
294
|
+
const options = parseAccountOptions(args);
|
|
295
|
+
const accountId = await resolveAccountId(options.account);
|
|
296
|
+
const response = await apiFetch(`/v0/accounts/${encodeURIComponent(accountId)}/status`, {
|
|
297
|
+
method: "GET"
|
|
298
|
+
}, { accountId: options.account, accountScoped: true });
|
|
299
|
+
console.log(`account_id=${response.account_id}`);
|
|
300
|
+
if (response.plan_key)
|
|
301
|
+
console.log(`plan_key=${response.plan_key}`);
|
|
302
|
+
console.log(`billing_access_state=${response.billing_access_state}`);
|
|
303
|
+
console.log(`grace_ends_at=${response.grace_ends_at ?? ""}`);
|
|
304
|
+
if (response.suspended !== undefined)
|
|
305
|
+
console.log(`suspended=${response.suspended}`);
|
|
306
|
+
if (response.restricted !== undefined)
|
|
307
|
+
console.log(`restricted=${response.restricted}`);
|
|
308
|
+
printList("account_flags", response.account_flags ?? response.active_flags);
|
|
309
|
+
printList("reasons", response.reasons);
|
|
310
|
+
printWarnings(response.warnings);
|
|
311
|
+
}
|
|
312
|
+
async function accountLimitsCommand(args) {
|
|
313
|
+
const options = parseAccountOptions(args);
|
|
314
|
+
const accountId = await resolveAccountId(options.account);
|
|
315
|
+
const response = await apiFetch(`/v0/accounts/${encodeURIComponent(accountId)}/limits`, {
|
|
316
|
+
method: "GET"
|
|
317
|
+
}, { accountId: options.account, accountScoped: true });
|
|
318
|
+
console.log(`account_id=${response.account_id}`);
|
|
319
|
+
console.log(`plan_key=${response.plan_key}`);
|
|
320
|
+
if (response.usage_period?.period_start)
|
|
321
|
+
console.log(`usage_period_start=${response.usage_period.period_start}`);
|
|
322
|
+
if (response.usage_period?.period_end)
|
|
323
|
+
console.log(`usage_period_end=${response.usage_period.period_end}`);
|
|
324
|
+
printKeyValues("feature", response.features);
|
|
325
|
+
printKeyValues("manifest_limit", response.manifest_limits);
|
|
326
|
+
printKeyValues("deployment_limit", response.deployment_limits);
|
|
327
|
+
printKeyValues("runtime_limit", response.runtime_limits);
|
|
328
|
+
printKeyValues("release_limit", response.release_limits);
|
|
329
|
+
printKeyValues("usage_limit", response.usage_limits);
|
|
330
|
+
printKeyValues("usage", response.usage);
|
|
331
|
+
if (response.route_counts)
|
|
332
|
+
printKeyValues("route_count", response.route_counts);
|
|
333
|
+
printWarnings(response.compatibility_warnings);
|
|
334
|
+
}
|
|
335
|
+
async function downgradePreviewCommand(args) {
|
|
336
|
+
const options = parseDowngradePreviewOptions(args);
|
|
337
|
+
if (!options.to) {
|
|
338
|
+
usage(1);
|
|
339
|
+
}
|
|
340
|
+
const accountId = await resolveAccountId(options.account);
|
|
341
|
+
const params = new URLSearchParams({ plan: options.to });
|
|
342
|
+
const response = await apiFetch(`/v0/accounts/${encodeURIComponent(accountId)}/downgrade-preview?${params.toString()}`, {
|
|
343
|
+
method: "GET"
|
|
344
|
+
}, { accountId: options.account, accountScoped: true });
|
|
345
|
+
console.log(`account_id=${response.account_id}`);
|
|
346
|
+
console.log(`current_plan_key=${response.current_plan_key}`);
|
|
347
|
+
console.log(`target_plan_key=${response.target_plan_key}`);
|
|
348
|
+
console.log(`compatible=${response.compatible}`);
|
|
349
|
+
for (const violation of response.violations) {
|
|
350
|
+
console.log(`violation=${formatFields(violation)}`);
|
|
351
|
+
}
|
|
352
|
+
for (const action of response.actions) {
|
|
353
|
+
console.log(`action=${formatFields(action)}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
206
356
|
async function publishCommand(args) {
|
|
207
357
|
const dir = args[0];
|
|
208
358
|
const options = parseOptions(args.slice(1));
|
|
@@ -306,6 +456,185 @@ async function eventsCommand(args) {
|
|
|
306
456
|
console.log(`cursor=${response.cursor}`);
|
|
307
457
|
}
|
|
308
458
|
}
|
|
459
|
+
async function appStatusCommand(args) {
|
|
460
|
+
const appId = args[0];
|
|
461
|
+
if (!appId) {
|
|
462
|
+
usage(1);
|
|
463
|
+
}
|
|
464
|
+
const options = parseAccountOptions(args.slice(1));
|
|
465
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}`, {
|
|
466
|
+
method: "GET"
|
|
467
|
+
}, { accountId: options.account, accountScoped: true });
|
|
468
|
+
const state = objectValue(response.operational_state) ?? {};
|
|
469
|
+
console.log(`app_id=${response.app_id}`);
|
|
470
|
+
if (response.account_id)
|
|
471
|
+
console.log(`account_id=${response.account_id}`);
|
|
472
|
+
if (stringValue(state.billing_access_state) ?? response.billing_access_state)
|
|
473
|
+
console.log(`billing_access_state=${stringValue(state.billing_access_state) ?? response.billing_access_state}`);
|
|
474
|
+
printOptionalBoolean("suspended", state.suspended ?? response.suspended);
|
|
475
|
+
printOptionalBoolean("takedown", state.takedown ?? response.takedown);
|
|
476
|
+
printOptionalBoolean("restricted", state.restricted);
|
|
477
|
+
printOptionalBoolean("can_serve_canonical", state.can_serve_canonical ?? response.can_serve_canonical);
|
|
478
|
+
printOptionalBoolean("can_mutate", state.can_mutate ?? response.can_mutate);
|
|
479
|
+
printList("account_flags", stringArrayValue(state.account_flags) ?? response.account_flags);
|
|
480
|
+
printList("app_flags", stringArrayValue(state.app_flags) ?? response.app_flags);
|
|
481
|
+
printList("reasons", stringArrayValue(state.reasons) ?? response.reasons);
|
|
482
|
+
if (response.routes) {
|
|
483
|
+
for (const route of response.routes) {
|
|
484
|
+
printRoute(route);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async function routesListCommand(args) {
|
|
489
|
+
const appId = args[0];
|
|
490
|
+
if (!appId) {
|
|
491
|
+
usage(1);
|
|
492
|
+
}
|
|
493
|
+
const options = parseAccountOptions(args.slice(1));
|
|
494
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/routes`, {
|
|
495
|
+
method: "GET"
|
|
496
|
+
}, { accountId: options.account, accountScoped: true });
|
|
497
|
+
printRoutes(response.routes);
|
|
498
|
+
}
|
|
499
|
+
async function appSlugsCommand(args) {
|
|
500
|
+
const [action, appId, slug, ...optionArgs] = args;
|
|
501
|
+
if (action === "list" && appId) {
|
|
502
|
+
const options = parseAccountOptions([slug, ...optionArgs].filter((value) => value !== undefined));
|
|
503
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/slugs`, {
|
|
504
|
+
method: "GET"
|
|
505
|
+
}, { accountId: options.account, accountScoped: true });
|
|
506
|
+
printRoutes(response.routes);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (action === "add" && appId && slug) {
|
|
510
|
+
const options = parseAccountOptions(optionArgs);
|
|
511
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/slugs`, {
|
|
512
|
+
method: "POST",
|
|
513
|
+
body: JSON.stringify({ slug })
|
|
514
|
+
}, { accountId: options.account, accountScoped: true });
|
|
515
|
+
printRoute(response.route);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (action === "remove" && appId && slug) {
|
|
519
|
+
const options = parseAccountOptions(optionArgs);
|
|
520
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/slugs/${encodeURIComponent(slug)}`, {
|
|
521
|
+
method: "DELETE"
|
|
522
|
+
}, { accountId: options.account, accountScoped: true });
|
|
523
|
+
printRoute(response.route);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
usage(1);
|
|
527
|
+
}
|
|
528
|
+
async function appDomainsCommand(args) {
|
|
529
|
+
const [action, appId, hostname, ...optionArgs] = args;
|
|
530
|
+
if (action === "list" && appId) {
|
|
531
|
+
const options = parseAccountOptions([hostname, ...optionArgs].filter((value) => value !== undefined));
|
|
532
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains`, {
|
|
533
|
+
method: "GET"
|
|
534
|
+
}, { accountId: options.account, accountScoped: true });
|
|
535
|
+
printRoutes(response.routes);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (action === "add" && appId && hostname) {
|
|
539
|
+
const options = parseAccountOptions(optionArgs);
|
|
540
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains`, {
|
|
541
|
+
method: "POST",
|
|
542
|
+
body: JSON.stringify({ hostname })
|
|
543
|
+
}, { accountId: options.account, accountScoped: true });
|
|
544
|
+
printRoute(response.route);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (action === "verify" && appId && hostname) {
|
|
548
|
+
const options = parseAccountOptions(optionArgs);
|
|
549
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains/${encodeURIComponent(hostname)}/verify`, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
body: JSON.stringify({})
|
|
552
|
+
}, { accountId: options.account, accountScoped: true });
|
|
553
|
+
printRoute(response.route);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (action === "remove" && appId && hostname) {
|
|
557
|
+
const options = parseAccountOptions(optionArgs);
|
|
558
|
+
const response = await apiFetch(`/v0/apps/${encodeURIComponent(appId)}/domains/${encodeURIComponent(hostname)}`, {
|
|
559
|
+
method: "DELETE"
|
|
560
|
+
}, { accountId: options.account, accountScoped: true });
|
|
561
|
+
printRoute(response.route);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
usage(1);
|
|
565
|
+
}
|
|
566
|
+
async function opsFlagCommand(scope, id, flag, args, clear) {
|
|
567
|
+
const options = parseReasonOptions(args);
|
|
568
|
+
return await apiFetch(`/v0/ops/${scope}/${encodeURIComponent(id)}/flags/${encodeURIComponent(flag)}${clear ? "/clear" : ""}`, {
|
|
569
|
+
method: clear ? "POST" : "PUT",
|
|
570
|
+
body: JSON.stringify(bodyWithReason(options))
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
async function resolveAccountId(explicitAccountId) {
|
|
574
|
+
const credentials = await readCredentials();
|
|
575
|
+
const accountId = selectedAccountId(explicitAccountId, credentials);
|
|
576
|
+
if (accountId) {
|
|
577
|
+
return accountId;
|
|
578
|
+
}
|
|
579
|
+
const response = await apiFetch("/v0/accounts", { method: "GET" });
|
|
580
|
+
return response.default_account_id;
|
|
581
|
+
}
|
|
582
|
+
function printRoutes(routes) {
|
|
583
|
+
for (const route of routes) {
|
|
584
|
+
printRoute(route);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function printRoute(route) {
|
|
588
|
+
console.log([
|
|
589
|
+
route.route_id,
|
|
590
|
+
route.route_type,
|
|
591
|
+
route.status,
|
|
592
|
+
route.hostname,
|
|
593
|
+
route.slug ?? "",
|
|
594
|
+
route.reason ?? ""
|
|
595
|
+
].join("\t"));
|
|
596
|
+
if (route.verification && Object.keys(route.verification).length > 0) {
|
|
597
|
+
console.log(`verification=${JSON.stringify(route.verification)}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function printObject(value) {
|
|
601
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
602
|
+
if (Array.isArray(entry)) {
|
|
603
|
+
for (const item of entry) {
|
|
604
|
+
console.log(`${key}=${isPlainObject(item) ? formatFields(item) : String(item)}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
else if (isPlainObject(entry)) {
|
|
608
|
+
console.log(`${key}=${JSON.stringify(entry)}`);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
console.log(`${key}=${entry ?? ""}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function printKeyValues(prefix, values) {
|
|
616
|
+
for (const [key, value] of Object.entries(values).sort(([left], [right]) => left.localeCompare(right))) {
|
|
617
|
+
console.log(`${prefix}=${key} value=${value === null ? "unlimited" : formatFieldValue(value)}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function printList(label, values) {
|
|
621
|
+
if (values && values.length > 0) {
|
|
622
|
+
console.log(`${label}=${values.join(",")}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function printWarnings(warnings) {
|
|
626
|
+
for (const warning of warnings ?? []) {
|
|
627
|
+
console.log(`warning=${formatFields(warning)}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function printOptionalBoolean(label, value) {
|
|
631
|
+
if (typeof value === "boolean") {
|
|
632
|
+
console.log(`${label}=${value}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function bodyWithReason(options) {
|
|
636
|
+
return options.reason ? { reason: options.reason } : {};
|
|
637
|
+
}
|
|
309
638
|
async function readPublishDirectory(rootDir, options) {
|
|
310
639
|
const absoluteRoot = path.resolve(rootDir);
|
|
311
640
|
const stat = await fs.stat(absoluteRoot).catch(() => null);
|
|
@@ -853,6 +1182,54 @@ function parseEventsOptions(args) {
|
|
|
853
1182
|
}
|
|
854
1183
|
return options;
|
|
855
1184
|
}
|
|
1185
|
+
function parseDowngradePreviewOptions(args) {
|
|
1186
|
+
const options = {};
|
|
1187
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1188
|
+
const arg = args[index];
|
|
1189
|
+
if (arg === "--to") {
|
|
1190
|
+
options.to = args[++index];
|
|
1191
|
+
}
|
|
1192
|
+
else if (arg === "--account") {
|
|
1193
|
+
options.account = args[++index];
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return options;
|
|
1200
|
+
}
|
|
1201
|
+
function parseReasonOptions(args) {
|
|
1202
|
+
const options = {};
|
|
1203
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1204
|
+
const arg = args[index];
|
|
1205
|
+
if (arg === "--reason") {
|
|
1206
|
+
options.reason = args[++index];
|
|
1207
|
+
}
|
|
1208
|
+
else if (arg === "--account") {
|
|
1209
|
+
options.account = args[++index];
|
|
1210
|
+
}
|
|
1211
|
+
else {
|
|
1212
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return options;
|
|
1216
|
+
}
|
|
1217
|
+
function parseRouteDisableOptions(args) {
|
|
1218
|
+
const options = {};
|
|
1219
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1220
|
+
const arg = args[index];
|
|
1221
|
+
if (arg === "--status") {
|
|
1222
|
+
options.status = args[++index];
|
|
1223
|
+
}
|
|
1224
|
+
else if (arg === "--reason") {
|
|
1225
|
+
options.reason = args[++index];
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return options;
|
|
1232
|
+
}
|
|
856
1233
|
function parseAccountOptions(args) {
|
|
857
1234
|
const options = {};
|
|
858
1235
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -940,6 +1317,27 @@ function objectValue(value) {
|
|
|
940
1317
|
function stringValue(value) {
|
|
941
1318
|
return typeof value === "string" ? value : undefined;
|
|
942
1319
|
}
|
|
1320
|
+
function stringArrayValue(value) {
|
|
1321
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : undefined;
|
|
1322
|
+
}
|
|
1323
|
+
function formatFields(value) {
|
|
1324
|
+
return Object.entries(value)
|
|
1325
|
+
.filter(([, entry]) => entry !== undefined && entry !== null)
|
|
1326
|
+
.map(([key, entry]) => `${key}=${formatFieldValue(entry)}`)
|
|
1327
|
+
.join(" ");
|
|
1328
|
+
}
|
|
1329
|
+
function formatFieldValue(value) {
|
|
1330
|
+
if (typeof value === "string") {
|
|
1331
|
+
return /\s/u.test(value) ? JSON.stringify(value) : value;
|
|
1332
|
+
}
|
|
1333
|
+
if (Array.isArray(value)) {
|
|
1334
|
+
return value.map(formatFieldValue).join(",");
|
|
1335
|
+
}
|
|
1336
|
+
if (isPlainObject(value)) {
|
|
1337
|
+
return JSON.stringify(value);
|
|
1338
|
+
}
|
|
1339
|
+
return String(value);
|
|
1340
|
+
}
|
|
943
1341
|
function contentTypeForPath(filePath) {
|
|
944
1342
|
const ext = path.extname(filePath).toLowerCase();
|
|
945
1343
|
const types = {
|
|
@@ -958,46 +1356,57 @@ function contentTypeForPath(filePath) {
|
|
|
958
1356
|
return types[ext] ?? "application/octet-stream";
|
|
959
1357
|
}
|
|
960
1358
|
function errorMessage(body) {
|
|
961
|
-
if (
|
|
1359
|
+
if (!isPlainObject(body) || !("error" in body)) {
|
|
962
1360
|
return undefined;
|
|
963
1361
|
}
|
|
964
|
-
const
|
|
965
|
-
if (
|
|
1362
|
+
const parsed = parseApiError(body);
|
|
1363
|
+
if (!parsed.message && !parsed.code) {
|
|
966
1364
|
return undefined;
|
|
967
1365
|
}
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
return
|
|
1366
|
+
const lines = [parsed.message ?? parsed.code];
|
|
1367
|
+
if (parsed.code) {
|
|
1368
|
+
lines.push(`error=${parsed.code}`);
|
|
1369
|
+
}
|
|
1370
|
+
lines.push(...structuredDetailLines(parsed.details));
|
|
1371
|
+
return lines.filter(Boolean).join("\n");
|
|
974
1372
|
}
|
|
975
|
-
function
|
|
1373
|
+
function parseApiError(body) {
|
|
1374
|
+
const error = body.error;
|
|
1375
|
+
if (typeof error === "string") {
|
|
1376
|
+
return {
|
|
1377
|
+
code: error,
|
|
1378
|
+
message: stringValue(body.message),
|
|
1379
|
+
details: body.details
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
if (!isPlainObject(error)) {
|
|
1383
|
+
return {};
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
code: stringValue(error.code),
|
|
1387
|
+
message: stringValue(error.message),
|
|
1388
|
+
details: error.details
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
function structuredDetailLines(details) {
|
|
976
1392
|
if (!isPlainObject(details)) {
|
|
977
|
-
return
|
|
1393
|
+
return [];
|
|
978
1394
|
}
|
|
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
1395
|
const lines = [];
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
lines.push("Violations:");
|
|
990
|
-
lines.push(...violations.map((violation) => `- ${violation}`));
|
|
1396
|
+
for (const key of ["metric", "plan_key", "required_plan_key", "limit", "limit_key", "current", "increment", "value", "upgrade_required"]) {
|
|
1397
|
+
if (details[key] !== undefined) {
|
|
1398
|
+
lines.push(`${key}=${formatFieldValue(details[key])}`);
|
|
1399
|
+
}
|
|
991
1400
|
}
|
|
992
|
-
|
|
993
|
-
|
|
1401
|
+
const violations = Array.isArray(details.violations) ? details.violations : [];
|
|
1402
|
+
for (const violation of violations) {
|
|
1403
|
+
if (isPlainObject(violation)) {
|
|
1404
|
+
lines.push(`violation=${formatViolation(violation)}`);
|
|
1405
|
+
}
|
|
994
1406
|
}
|
|
995
|
-
return lines
|
|
1407
|
+
return lines;
|
|
996
1408
|
}
|
|
997
|
-
function
|
|
998
|
-
if (!isPlainObject(value)) {
|
|
999
|
-
return undefined;
|
|
1000
|
-
}
|
|
1409
|
+
function formatViolation(value) {
|
|
1001
1410
|
const path = stringValue(value.manifest_path);
|
|
1002
1411
|
const feature = stringValue(value.feature_key);
|
|
1003
1412
|
const limit = stringValue(value.limit_key);
|
|
@@ -1012,7 +1421,7 @@ function formatEntitlementViolation(value) {
|
|
|
1012
1421
|
allowed !== undefined ? `allowed=${formatAllowed(allowed)}` : undefined,
|
|
1013
1422
|
requiredPlan ? `requires=${requiredPlan}` : undefined
|
|
1014
1423
|
].filter(Boolean);
|
|
1015
|
-
return parts.length > 0 ? parts.join(" ") :
|
|
1424
|
+
return parts.length > 0 ? parts.join(" ") : formatFields(value);
|
|
1016
1425
|
}
|
|
1017
1426
|
function formatAllowed(value) {
|
|
1018
1427
|
return Array.isArray(value) ? value.join(",") : String(value);
|
|
@@ -1021,7 +1430,7 @@ function isPlainObject(value) {
|
|
|
1021
1430
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1022
1431
|
}
|
|
1023
1432
|
function docsUrlForError(message) {
|
|
1024
|
-
if (message.includes("entitlement_required") || message.includes("plan_limit_exceeded")) {
|
|
1433
|
+
if (message.includes("entitlement_required") || message.includes("plan_limit_exceeded") || message.includes("quota_exceeded") || message.includes("downgrade_incompatible")) {
|
|
1025
1434
|
return "https://docs.userland.fun/reference/errors";
|
|
1026
1435
|
}
|
|
1027
1436
|
if (message.includes("USERLAND_API_KEY") || message.includes("credentials")) {
|
|
@@ -1047,18 +1456,40 @@ function usage(exitCode) {
|
|
|
1047
1456
|
userland auth save-key --username <username> --api-key <api-key> [--password <password>]
|
|
1048
1457
|
userland accounts list
|
|
1049
1458
|
userland accounts use <account-id>
|
|
1459
|
+
userland accounts status [--account <account-id>]
|
|
1460
|
+
userland accounts limits [--account <account-id>]
|
|
1461
|
+
userland accounts downgrade preview --to <plan> [--account <account-id>]
|
|
1050
1462
|
userland apps publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
|
|
1051
1463
|
userland apps list [--account <account-id>]
|
|
1464
|
+
userland apps status <app-id> [--account <account-id>]
|
|
1052
1465
|
userland apps releases <app-id> [--account <account-id>]
|
|
1053
1466
|
userland apps rollback <app-id> <release-id> [--account <account-id>]
|
|
1054
1467
|
userland apps secrets set <app-id> <NAME> [--value <value>] [--account <account-id>]
|
|
1055
1468
|
userland apps events <app-id> [--type <event-type>] [--severity <level>] [--release <release-id>] [--limit <n>] [--account <account-id>]
|
|
1469
|
+
userland apps routes list <app-id> [--account <account-id>]
|
|
1470
|
+
userland apps slugs list <app-id> [--account <account-id>]
|
|
1471
|
+
userland apps slugs add <app-id> <slug> [--account <account-id>]
|
|
1472
|
+
userland apps slugs remove <app-id> <slug> [--account <account-id>]
|
|
1473
|
+
userland apps domains list <app-id> [--account <account-id>]
|
|
1474
|
+
userland apps domains add <app-id> <hostname> [--account <account-id>]
|
|
1475
|
+
userland apps domains verify <app-id> <hostname> [--account <account-id>]
|
|
1476
|
+
userland apps domains remove <app-id> <hostname> [--account <account-id>]
|
|
1477
|
+
userland ops accounts status <account-id>
|
|
1478
|
+
userland ops accounts flag <account-id> <flag> [--reason <text>]
|
|
1479
|
+
userland ops accounts clear <account-id> <flag> [--reason <text>]
|
|
1480
|
+
userland ops apps status <app-id>
|
|
1481
|
+
userland ops apps flag <app-id> <flag> [--reason <text>]
|
|
1482
|
+
userland ops apps clear <app-id> <flag> [--reason <text>]
|
|
1483
|
+
userland ops apps takedown <app-id> [--reason <text>]
|
|
1484
|
+
userland ops routes disable <route-id> --status <disabled_abuse|disabled_billing|disabled_downgrade> [--reason <text>]
|
|
1485
|
+
userland ops routes enable <route-id> [--reason <text>]
|
|
1056
1486
|
|
|
1057
1487
|
Aliases:
|
|
1058
1488
|
userland auth signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
|
|
1059
1489
|
userland auth login [--username <username>] [--password <password>] [--no-save]
|
|
1060
1490
|
userland publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
|
|
1061
1491
|
userland releases <app-id> [--account <account-id>]
|
|
1492
|
+
userland versions <app-id> [--account <account-id>]
|
|
1062
1493
|
|
|
1063
1494
|
Credentials:
|
|
1064
1495
|
Commands use USERLAND_API_KEY first, then ~/.userland/credentials.json for API keys.
|