@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.
Files changed (3) hide show
  1. package/README.md +83 -7
  2. package/dist/index.js +664 -399
  3. 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 KEYCHAIN_SERVICE = "fun.userland.cli";
9
- const KEYCHAIN_ACCOUNT = "default";
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 signupCommand(args) {
106
- const options = parseAuthOptions(args);
107
- const username = options.username ?? (await promptRequired("Username: "));
108
- const password = options.password ?? (await promptPassword("Password: "));
109
- const body = { username, password };
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
- const response = await unauthenticatedApiFetch("/v0/accounts", {
114
- method: "POST",
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
- console.log(`Created Userland account ${response.username}`);
130
- console.log(`api_key=${response.api_key}`);
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
- const storedAccount = await readAccountCredentials();
135
- const username = options.username ?? storedAccount?.username ?? (await promptRequired("Username: "));
136
- const password = options.password ?? storedAccount?.password ?? (await promptPassword("Password: "));
137
- const response = await unauthenticatedApiFetch("/v0/auth/token", {
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({ username, password })
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
- account_id: accountId ?? null
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
- console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
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
- console.log(`account_login=${account ? "keychain" : "missing"}`);
171
- if (account?.username) {
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
- await saveAccountCredentials({ username, password: options.password });
289
+ const credentials = await readCredentials();
180
290
  const filePath = await saveCredentials({
181
291
  api_key: apiKey,
182
- api_base_url: await apiBaseUrl(),
183
- account_id: null
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
- console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
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
- async function unauthenticatedApiFetch(apiPath, init) {
438
- return await requestJson(await apiBaseUrl(), apiPath, init);
816
+ function selectedAccountId(explicitAccountId, credentials) {
817
+ return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
439
818
  }
440
- async function discoverDefaultAccountId(apiKey, baseUrl) {
441
- const response = await requestJson(baseUrl, "/v0/accounts", {
442
- method: "GET",
443
- headers: {
444
- authorization: `Bearer ${apiKey}`
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
- return response.default_account_id;
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 selectedAccountId(explicitAccountId, credentials) {
450
- return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
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
- updated_at: stringValue(credentials.updated_at)
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
- if (update.account_id === null) {
507
- delete credentials.account_id;
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
- options.username = args[++index];
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 (typeof body !== "object" || body === null || !("error" in body)) {
1192
+ if (!isPlainObject(body) || !("error" in body)) {
962
1193
  return undefined;
963
1194
  }
964
- const error = body.error;
965
- if (typeof error !== "object" || error === null || !("message" in error)) {
1195
+ const parsed = parseApiError(body);
1196
+ if (!parsed.message && !parsed.code) {
966
1197
  return undefined;
967
1198
  }
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");
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 entitlementDetailMessage(details) {
1224
+ function structuredDetailLines(details) {
976
1225
  if (!isPlainObject(details)) {
977
- return undefined;
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
- 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}`));
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
- else if (limit) {
993
- lines.push(`Limit: ${limit}${value !== undefined ? ` value=${String(value)}` : ""}${allowed !== undefined ? ` allowed=${formatAllowed(allowed)}` : ""}`);
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.length > 0 ? lines.join("\n") : undefined;
1240
+ return lines;
996
1241
  }
997
- function formatEntitlementViolation(value) {
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(" ") : undefined;
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 [--username <username>] [--password <password>] [--email <email>] [--no-save]
1045
- userland login [--username <username>] [--password <password>] [--no-save]
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 --username <username> --api-key <api-key> [--password <password>]
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 [--username <username>] [--password <password>] [--email <email>] [--no-save]
1059
- userland auth login [--username <username>] [--password <password>] [--no-save]
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
- Account username and password are stored in the OS keychain.
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