@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.
Files changed (3) hide show
  1. package/README.md +68 -0
  2. package/dist/index.js +462 -31
  3. 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 (typeof body !== "object" || body === null || !("error" in body)) {
1359
+ if (!isPlainObject(body) || !("error" in body)) {
962
1360
  return undefined;
963
1361
  }
964
- const error = body.error;
965
- if (typeof error !== "object" || error === null || !("message" in error)) {
1362
+ const parsed = parseApiError(body);
1363
+ if (!parsed.message && !parsed.code) {
966
1364
  return undefined;
967
1365
  }
968
- const typedError = error;
969
- const message = typeof typedError.message === "string" ? typedError.message : undefined;
970
- const code = typeof typedError.code === "string" ? typedError.code : undefined;
971
- const detail = entitlementDetailMessage(typedError.details);
972
- const base = code && message ? `${code}: ${message}` : message ?? code;
973
- return [base, detail].filter(Boolean).join("\n");
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 entitlementDetailMessage(details) {
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 undefined;
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
- if (requiredPlan) {
986
- lines.push(`Required plan: ${requiredPlan}`);
987
- }
988
- if (violations.length > 0) {
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
- else if (limit) {
993
- lines.push(`Limit: ${limit}${value !== undefined ? ` value=${String(value)}` : ""}${allowed !== undefined ? ` allowed=${formatAllowed(allowed)}` : ""}`);
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.length > 0 ? lines.join("\n") : undefined;
1407
+ return lines;
996
1408
  }
997
- function formatEntitlementViolation(value) {
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(" ") : undefined;
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@userland.fun/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Userland command-line tools for publishing and operating apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",