@userland.fun/cli 0.2.0 → 0.3.1

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 +15 -7
  2. package/dist/index.js +211 -370
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,10 +17,13 @@ npm install -g @userland.fun/cli
17
17
  Then run:
18
18
 
19
19
  ```sh
20
- userland signup --username <username>
21
- userland login --username <username>
20
+ userland login
21
+ userland login --no-browser
22
+ userland signup
22
23
  userland auth status
23
- userland auth save-key --username <username> --api-key <api-key>
24
+ userland auth save-key --api-key <api-key>
25
+ userland auth logout
26
+ userland auth logout --revoke
24
27
  userland accounts list
25
28
  userland accounts use <account-id>
26
29
  userland accounts status --account <account-id>
@@ -45,10 +48,13 @@ userland apps domains verify <app-id> <hostname>
45
48
  From this repo, the same commands can be run from source:
46
49
 
47
50
  ```sh
48
- npm run userland -- signup --username <username>
49
- npm run userland -- login --username <username>
51
+ npm run userland -- login
52
+ npm run userland -- login --no-browser
53
+ npm run userland -- signup
50
54
  npm run userland -- auth status
51
- npm run userland -- auth save-key --username <username> --api-key <api-key>
55
+ npm run userland -- auth save-key --api-key <api-key>
56
+ npm run userland -- auth logout
57
+ npm run userland -- auth logout --revoke
52
58
  npm run userland -- accounts list
53
59
  npm run userland -- accounts use <account-id>
54
60
  npm run userland -- accounts status --account <account-id>
@@ -70,7 +76,9 @@ npm run userland -- apps domains add <app-id> <hostname>
70
76
  npm run userland -- apps domains verify <app-id> <hostname>
71
77
  ```
72
78
 
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.
79
+ `login` starts a browser device-authorization flow. The CLI prints a verification URL and user code, opens the browser when possible, waits for approval, then saves the returned API key to `~/.userland/credentials.json` with `0600` permissions. `signup` is an alias for the same flow; if the email is new, account creation happens in the browser after email proof.
80
+
81
+ The CLI does not store platform passwords. App commands prefer `USERLAND_API_KEY` when it is set, then fall back to the saved API key. `auth save-key` remains available for CI, support, and manually copied API keys.
74
82
 
75
83
  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.
76
84
 
package/dist/index.js CHANGED
@@ -1,12 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { promises as fs } from "node:fs";
3
+ import { promises as fs, readFileSync } from "node:fs";
4
4
  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 = readCliVersion();
10
+ function readCliVersion() {
11
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
12
+ if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
13
+ throw new Error("Unable to read CLI package version");
14
+ }
15
+ return packageJson.version;
16
+ }
10
17
  async function main() {
11
18
  const [command, ...args] = process.argv.slice(2);
12
19
  if (isHelpCommand(command)) {
@@ -108,6 +115,10 @@ async function authCommand(args) {
108
115
  await saveKeyCommand(rest);
109
116
  return;
110
117
  }
118
+ if (subcommand === "logout") {
119
+ await logoutCommand(rest);
120
+ return;
121
+ }
111
122
  usage(1);
112
123
  }
113
124
  async function accountsCommand(args) {
@@ -191,86 +202,132 @@ async function opsCommand(args) {
191
202
  }
192
203
  async function signupCommand(args) {
193
204
  const options = parseAuthOptions(args);
194
- const username = options.username ?? (await promptRequired("Username: "));
195
- const password = options.password ?? (await promptPassword("Password: "));
196
- const body = { username, password };
197
- if (options.email) {
198
- body.email = options.email;
199
- }
200
- const response = await unauthenticatedApiFetch("/v0/accounts", {
201
- method: "POST",
202
- body: JSON.stringify(body)
203
- });
204
- if (options.save !== false) {
205
- await saveAccountCredentials({ username: response.username, password });
206
- const filePath = await saveCredentials({
207
- api_key: response.api_key,
208
- api_base_url: await apiBaseUrl(),
209
- account_id: response.account_id ?? null
210
- });
211
- console.log(`Created Userland account ${response.username}`);
212
- console.log(`Saved API key to ${filePath}`);
213
- console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
214
- return;
215
- }
216
- console.log(`Created Userland account ${response.username}`);
217
- console.log(`api_key=${response.api_key}`);
205
+ await deviceLoginCommand(options, { signupAlias: true });
218
206
  }
219
207
  async function loginCommand(args) {
220
208
  const options = parseAuthOptions(args);
221
- const storedAccount = await readAccountCredentials();
222
- const username = options.username ?? storedAccount?.username ?? (await promptRequired("Username: "));
223
- const password = options.password ?? storedAccount?.password ?? (await promptPassword("Password: "));
224
- const response = await unauthenticatedApiFetch("/v0/auth/token", {
209
+ await deviceLoginCommand(options, { signupAlias: false });
210
+ }
211
+ async function deviceLoginCommand(options, context) {
212
+ const credentials = await readCredentials();
213
+ const baseUrl = await apiBaseUrl(credentials, options.apiBaseUrl);
214
+ const configuredConsoleUrl = await consoleBaseUrl(credentials, options.consoleUrl);
215
+ const start = await requestJson(baseUrl, "/v0/auth/device/start", {
225
216
  method: "POST",
226
- body: JSON.stringify({ username, password })
217
+ body: JSON.stringify({
218
+ client: "userland-cli",
219
+ client_version: CLI_VERSION,
220
+ requested_capability: "api_key"
221
+ })
227
222
  });
223
+ const verificationUrl = options.consoleUrl
224
+ ? `${configuredConsoleUrl.replace(/\/$/u, "")}/device?code=${encodeURIComponent(start.user_code)}`
225
+ : start.verification_uri_complete;
226
+ const consoleUrl = options.consoleUrl ?? consoleUrlFromVerification(start.verification_uri, configuredConsoleUrl);
227
+ if (context.signupAlias) {
228
+ console.log("Signup uses the same browser approval flow as login. New accounts are created in the browser after email proof.");
229
+ }
230
+ if (options.email) {
231
+ console.log(`email_hint=${options.email}`);
232
+ }
233
+ if (!options.noBrowser) {
234
+ const opened = await openBrowser(verificationUrl);
235
+ if (opened) {
236
+ console.log("Opened your browser for Userland authorization.");
237
+ }
238
+ }
239
+ console.log("Open this URL to sign in to Userland:");
240
+ console.log("");
241
+ console.log(verificationUrl);
242
+ console.log("");
243
+ console.log(`user_code=${start.user_code}`);
244
+ console.log("Waiting for approval...");
245
+ const response = await pollDeviceAuthorization(baseUrl, start);
228
246
  if (options.save !== false) {
229
- const baseUrl = await apiBaseUrl();
230
- const accountId = response.account_id ?? (await discoverDefaultAccountId(response.api_key, baseUrl).catch(() => undefined));
231
- await saveAccountCredentials({ username, password });
232
247
  const filePath = await saveCredentials({
233
248
  api_key: response.api_key,
249
+ api_key_id: response.api_key_id ?? null,
234
250
  api_base_url: baseUrl,
235
- account_id: accountId ?? null
251
+ console_url: consoleUrl,
252
+ username: response.username ?? null,
253
+ account_id: response.default_account_id ?? null
236
254
  });
237
255
  console.log(`Saved API key to ${filePath}`);
238
- console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
256
+ if (response.username) {
257
+ console.log(`username=${response.username}`);
258
+ }
259
+ if (response.default_account_id) {
260
+ console.log(`selected_account_id=${response.default_account_id}`);
261
+ }
239
262
  return;
240
263
  }
241
264
  console.log(`api_key=${response.api_key}`);
265
+ if (response.api_key_id) {
266
+ console.log(`api_key_id=${response.api_key_id}`);
267
+ }
268
+ if (response.default_account_id) {
269
+ console.log(`selected_account_id=${response.default_account_id}`);
270
+ }
242
271
  }
243
272
  async function authStatusCommand() {
244
273
  const credentials = await readCredentials();
245
- const account = await readAccountCredentials();
246
274
  const filePath = credentialsPath();
247
275
  const apiKeySource = process.env.USERLAND_API_KEY ? "env" : credentials?.api_key ? "file" : "missing";
248
276
  const selectedAccountId = process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
249
277
  const accountSource = process.env.USERLAND_ACCOUNT_ID ? "env" : credentials?.account_id ? "file" : apiKeySource === "missing" ? "missing" : "default";
250
278
  console.log(`api_base_url=${await apiBaseUrl(credentials)}`);
279
+ console.log(`console_url=${await consoleBaseUrl(credentials)}`);
251
280
  console.log(`api_key=${apiKeySource}`);
252
281
  console.log(`credentials_file=${filePath}`);
282
+ if (apiKeySource === "file" && credentials?.api_key_id) {
283
+ console.log(`api_key_id=${credentials.api_key_id}`);
284
+ }
253
285
  console.log(`account=${accountSource}`);
254
286
  if (selectedAccountId) {
255
287
  console.log(`account_id=${selectedAccountId}`);
256
288
  }
257
- console.log(`account_login=${account ? "keychain" : "missing"}`);
258
- if (account?.username) {
259
- console.log(`username=${account.username}`);
289
+ if (apiKeySource === "file" && credentials?.username) {
290
+ console.log(`username=${credentials.username}`);
260
291
  }
261
292
  }
262
293
  async function saveKeyCommand(args) {
263
294
  const options = parseAuthOptions(args);
264
- const username = options.username ?? (await promptRequired("Username: "));
265
295
  const apiKey = options.apiKey ?? (await promptRequired("API key: "));
266
- await saveAccountCredentials({ username, password: options.password });
296
+ const credentials = await readCredentials();
267
297
  const filePath = await saveCredentials({
268
298
  api_key: apiKey,
269
- api_base_url: await apiBaseUrl(),
270
- account_id: null
299
+ api_key_id: null,
300
+ api_base_url: await apiBaseUrl(credentials, options.apiBaseUrl),
301
+ console_url: await consoleBaseUrl(credentials, options.consoleUrl),
302
+ username: null,
303
+ account_id: options.account ?? null
271
304
  });
272
305
  console.log(`Saved API key to ${filePath}`);
273
- console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
306
+ if (options.account) {
307
+ console.log(`selected_account_id=${options.account}`);
308
+ }
309
+ }
310
+ async function logoutCommand(args) {
311
+ const options = parseAuthOptions(args);
312
+ const credentials = await readCredentials();
313
+ const filePath = credentialsPath();
314
+ if (options.revoke) {
315
+ if (credentials?.api_key && credentials.api_key_id) {
316
+ await requestJson(await apiBaseUrl(credentials, options.apiBaseUrl), `/v0/auth/api-keys/${encodeURIComponent(credentials.api_key_id)}`, {
317
+ method: "DELETE",
318
+ headers: {
319
+ authorization: `Bearer ${credentials.api_key}`
320
+ }
321
+ });
322
+ console.log(`revoked_api_key_id=${credentials.api_key_id}`);
323
+ }
324
+ else {
325
+ console.log("revoke=skipped api_key_id_missing");
326
+ }
327
+ }
328
+ await fs.rm(filePath, { force: true });
329
+ console.log("local_credentials=removed");
330
+ console.log(`credentials_file=${filePath}`);
274
331
  }
275
332
  async function listAccountsCommand() {
276
333
  const response = await apiFetch("/v0/accounts", {
@@ -763,20 +820,50 @@ async function apiFetch(apiPath, init, options = {}) {
763
820
  headers
764
821
  });
765
822
  }
766
- async function unauthenticatedApiFetch(apiPath, init) {
767
- return await requestJson(await apiBaseUrl(), apiPath, init);
823
+ function selectedAccountId(explicitAccountId, credentials) {
824
+ return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
768
825
  }
769
- async function discoverDefaultAccountId(apiKey, baseUrl) {
770
- const response = await requestJson(baseUrl, "/v0/accounts", {
771
- method: "GET",
772
- headers: {
773
- authorization: `Bearer ${apiKey}`
826
+ async function pollDeviceAuthorization(baseUrl, start) {
827
+ let intervalSeconds = positiveNumber(start.interval, 5);
828
+ const deadline = Date.now() + positiveNumber(start.expires_in, 900) * 1000;
829
+ while (Date.now() <= deadline) {
830
+ const poll = await requestJson(baseUrl, "/v0/auth/device/poll", {
831
+ method: "POST",
832
+ body: JSON.stringify({ device_code: start.device_code })
833
+ });
834
+ if (poll.ok) {
835
+ return poll;
774
836
  }
775
- });
776
- return response.default_account_id;
837
+ if (poll.status === "authorization_pending") {
838
+ await sleep(intervalSeconds * 1000);
839
+ continue;
840
+ }
841
+ if (poll.status === "slow_down") {
842
+ intervalSeconds = positiveNumber(poll.interval, intervalSeconds + 5);
843
+ await sleep(intervalSeconds * 1000);
844
+ continue;
845
+ }
846
+ throw new Error(deviceAuthorizationStatusMessage(poll.status));
847
+ }
848
+ throw new Error("Device authorization expired before approval.");
777
849
  }
778
- function selectedAccountId(explicitAccountId, credentials) {
779
- return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
850
+ function deviceAuthorizationStatusMessage(status) {
851
+ if (status === "denied") {
852
+ return "Device authorization was denied in the browser.";
853
+ }
854
+ if (status === "expired") {
855
+ return "Device authorization expired before approval.";
856
+ }
857
+ return "Device authorization was already consumed. Run `userland login` again.";
858
+ }
859
+ function positiveNumber(value, fallback) {
860
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
861
+ }
862
+ async function sleep(milliseconds) {
863
+ if (milliseconds <= 0) {
864
+ return;
865
+ }
866
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
780
867
  }
781
868
  async function requestJson(baseUrl, apiPath, init) {
782
869
  const response = await fetch(`${baseUrl.replace(/\/$/u, "")}${apiPath}`, {
@@ -794,8 +881,38 @@ async function requestJson(baseUrl, apiPath, init) {
794
881
  }
795
882
  return body;
796
883
  }
797
- async function apiBaseUrl(credentials) {
798
- return process.env.USERLAND_API_BASE_URL ?? credentials?.api_base_url ?? (await readCredentials())?.api_base_url ?? DEFAULT_API_BASE_URL;
884
+ async function apiBaseUrl(credentials, override) {
885
+ return override ?? process.env.USERLAND_API_BASE_URL ?? credentials?.api_base_url ?? (await readCredentials())?.api_base_url ?? DEFAULT_API_BASE_URL;
886
+ }
887
+ async function consoleBaseUrl(credentials, override) {
888
+ return override ?? process.env.USERLAND_CONSOLE_URL ?? credentials?.console_url ?? (await readCredentials())?.console_url ?? DEFAULT_CONSOLE_BASE_URL;
889
+ }
890
+ function consoleUrlFromVerification(verificationUri, fallback) {
891
+ if (!verificationUri) {
892
+ return fallback;
893
+ }
894
+ try {
895
+ const url = new URL(verificationUri);
896
+ return `${url.origin}`;
897
+ }
898
+ catch {
899
+ return fallback;
900
+ }
901
+ }
902
+ async function openBrowser(url) {
903
+ const [command, args] = process.platform === "darwin"
904
+ ? ["open", [url]]
905
+ : process.platform === "win32"
906
+ ? ["cmd", ["/c", "start", "", url]]
907
+ : ["xdg-open", [url]];
908
+ return await new Promise((resolve) => {
909
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
910
+ child.once("error", () => resolve(false));
911
+ child.once("spawn", () => {
912
+ child.unref();
913
+ resolve(true);
914
+ });
915
+ });
799
916
  }
800
917
  function credentialsPath() {
801
918
  return process.env.USERLAND_CREDENTIALS_FILE ?? path.join(os.homedir(), ".userland", "credentials.json");
@@ -820,7 +937,10 @@ async function readCredentials() {
820
937
  account_id: stringValue(credentials.account_id),
821
938
  api_base_url: stringValue(credentials.api_base_url),
822
939
  api_key: stringValue(credentials.api_key),
823
- updated_at: stringValue(credentials.updated_at)
940
+ api_key_id: stringValue(credentials.api_key_id),
941
+ console_url: stringValue(credentials.console_url),
942
+ updated_at: stringValue(credentials.updated_at),
943
+ username: stringValue(credentials.username)
824
944
  };
825
945
  }
826
946
  async function saveCredentials(update) {
@@ -832,8 +952,10 @@ async function saveCredentials(update) {
832
952
  ...sanitizedUpdate,
833
953
  updated_at: new Date().toISOString()
834
954
  };
835
- if (update.account_id === null) {
836
- delete credentials.account_id;
955
+ for (const [key, value] of Object.entries(update)) {
956
+ if (value === null) {
957
+ delete credentials[key];
958
+ }
837
959
  }
838
960
  const dir = path.dirname(filePath);
839
961
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
@@ -842,261 +964,6 @@ async function saveCredentials(update) {
842
964
  await fs.chmod(filePath, 0o600).catch(() => undefined);
843
965
  return filePath;
844
966
  }
845
- async function readAccountCredentials() {
846
- const raw = await keychainGetSecret().catch((error) => {
847
- if (error instanceof KeychainUnavailableError) {
848
- return undefined;
849
- }
850
- throw error;
851
- });
852
- if (!raw) {
853
- return undefined;
854
- }
855
- const parsed = JSON.parse(raw);
856
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
857
- throw new Error("Stored Userland account credentials are malformed.");
858
- }
859
- const credentials = parsed;
860
- const username = stringValue(credentials.username);
861
- const password = stringValue(credentials.password);
862
- return username || password ? { username, password } : undefined;
863
- }
864
- async function saveAccountCredentials(update) {
865
- const existing = (await readAccountCredentials()) ?? {};
866
- const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
867
- const credentials = {
868
- ...existing,
869
- ...sanitizedUpdate
870
- };
871
- if (!credentials.username && !credentials.password) {
872
- return;
873
- }
874
- await keychainSetSecret(JSON.stringify(credentials));
875
- }
876
- function accountCredentialStoreLabel() {
877
- return process.env.USERLAND_KEYCHAIN_FILE ? "test keychain" : "OS keychain";
878
- }
879
- class KeychainUnavailableError extends Error {
880
- }
881
- async function keychainGetSecret() {
882
- const testKeychainFile = process.env.USERLAND_KEYCHAIN_FILE;
883
- if (testKeychainFile) {
884
- return await fileKeychainGet(testKeychainFile);
885
- }
886
- if (process.platform === "darwin") {
887
- const result = await runCommand("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"]);
888
- if (result.code === 44) {
889
- return undefined;
890
- }
891
- assertCommandOk("security", result);
892
- return result.stdout.trimEnd();
893
- }
894
- if (process.platform === "linux") {
895
- const result = await runCommand("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT]);
896
- if (result.code === 1) {
897
- return undefined;
898
- }
899
- assertCommandOk("secret-tool", result);
900
- return result.stdout.trimEnd();
901
- }
902
- if (process.platform === "win32") {
903
- const result = await runPowerShell(windowsCredentialReadScript());
904
- if (result.code === 2) {
905
- return undefined;
906
- }
907
- assertCommandOk("powershell", result);
908
- return result.stdout.trimEnd();
909
- }
910
- throw new KeychainUnavailableError(`OS keychain is not supported on ${process.platform}.`);
911
- }
912
- async function keychainSetSecret(secret) {
913
- const testKeychainFile = process.env.USERLAND_KEYCHAIN_FILE;
914
- if (testKeychainFile) {
915
- await fileKeychainSet(testKeychainFile, secret);
916
- return;
917
- }
918
- if (process.platform === "darwin") {
919
- assertCommandOk("security", await runCommand("security", ["add-generic-password", "-U", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w", secret]));
920
- return;
921
- }
922
- if (process.platform === "linux") {
923
- assertCommandOk("secret-tool", await runCommand("secret-tool", ["store", "--label", "Userland CLI", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT], secret));
924
- return;
925
- }
926
- if (process.platform === "win32") {
927
- assertCommandOk("powershell", await runPowerShell(windowsCredentialWriteScript(), secret));
928
- return;
929
- }
930
- throw new KeychainUnavailableError(`OS keychain is not supported on ${process.platform}.`);
931
- }
932
- async function fileKeychainGet(filePath) {
933
- const contents = await fs.readFile(filePath, "utf8").catch((error) => {
934
- if (error.code === "ENOENT") {
935
- return undefined;
936
- }
937
- throw error;
938
- });
939
- if (!contents) {
940
- return undefined;
941
- }
942
- const parsed = JSON.parse(contents);
943
- return parsed[`${KEYCHAIN_SERVICE}:${KEYCHAIN_ACCOUNT}`];
944
- }
945
- async function fileKeychainSet(filePath, secret) {
946
- const existing = await fs.readFile(filePath, "utf8").catch((error) => {
947
- if (error.code === "ENOENT") {
948
- return "{}";
949
- }
950
- throw error;
951
- });
952
- const parsed = JSON.parse(existing);
953
- parsed[`${KEYCHAIN_SERVICE}:${KEYCHAIN_ACCOUNT}`] = secret;
954
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
955
- await fs.writeFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
956
- await fs.chmod(filePath, 0o600).catch(() => undefined);
957
- }
958
- async function runCommand(command, args, stdin, env) {
959
- return await new Promise((resolve, reject) => {
960
- const child = spawn(command, args, { env: env ? { ...process.env, ...env } : process.env, stdio: ["pipe", "pipe", "pipe"] });
961
- const stdout = [];
962
- const stderr = [];
963
- child.stdout.on("data", (chunk) => stdout.push(chunk));
964
- child.stderr.on("data", (chunk) => stderr.push(chunk));
965
- child.on("error", (error) => {
966
- if (error.code === "ENOENT") {
967
- reject(new KeychainUnavailableError(`${command} is required for OS keychain access.`));
968
- return;
969
- }
970
- reject(error);
971
- });
972
- child.on("close", (code) => {
973
- resolve({
974
- code,
975
- stdout: Buffer.concat(stdout).toString("utf8"),
976
- stderr: Buffer.concat(stderr).toString("utf8")
977
- });
978
- });
979
- child.stdin.end(stdin ?? "");
980
- });
981
- }
982
- async function runPowerShell(script, stdin) {
983
- const env = stdin === undefined ? undefined : { USERLAND_KEYCHAIN_SECRET: stdin };
984
- return await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "-"], scriptWithInput(script), env);
985
- }
986
- function scriptWithInput(script) {
987
- return `$ErrorActionPreference = "Stop"\n${script}`;
988
- }
989
- function assertCommandOk(command, result) {
990
- if (result.code !== 0) {
991
- const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.code ?? "unknown"}`;
992
- throw new Error(`${command} failed while accessing the OS keychain: ${detail}`);
993
- }
994
- }
995
- function windowsCredentialWriteScript() {
996
- return `
997
- Add-Type -TypeDefinition @"
998
- using System;
999
- using System.ComponentModel;
1000
- using System.Runtime.InteropServices;
1001
- using System.Text;
1002
-
1003
- public static class UserlandCredential {
1004
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
1005
- private struct Credential {
1006
- public UInt32 Flags;
1007
- public UInt32 Type;
1008
- public string TargetName;
1009
- public string Comment;
1010
- public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
1011
- public UInt32 CredentialBlobSize;
1012
- public IntPtr CredentialBlob;
1013
- public UInt32 Persist;
1014
- public UInt32 AttributeCount;
1015
- public IntPtr Attributes;
1016
- public string TargetAlias;
1017
- public string UserName;
1018
- }
1019
-
1020
- [DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
1021
- private static extern bool CredWrite(ref Credential credential, UInt32 flags);
1022
-
1023
- public static void Write(string target, string username, string secret) {
1024
- byte[] bytes = Encoding.Unicode.GetBytes(secret);
1025
- IntPtr blob = Marshal.AllocCoTaskMem(bytes.Length);
1026
- try {
1027
- Marshal.Copy(bytes, 0, blob, bytes.Length);
1028
- Credential credential = new Credential();
1029
- credential.Type = 1;
1030
- credential.TargetName = target;
1031
- credential.UserName = username;
1032
- credential.CredentialBlob = blob;
1033
- credential.CredentialBlobSize = (UInt32)bytes.Length;
1034
- credential.Persist = 2;
1035
- if (!CredWrite(ref credential, 0)) {
1036
- throw new Win32Exception(Marshal.GetLastWin32Error());
1037
- }
1038
- } finally {
1039
- Marshal.FreeCoTaskMem(blob);
1040
- }
1041
- }
1042
- }
1043
- "@
1044
- $secret = [Environment]::GetEnvironmentVariable("USERLAND_KEYCHAIN_SECRET")
1045
- [UserlandCredential]::Write(${JSON.stringify(KEYCHAIN_SERVICE)}, ${JSON.stringify(KEYCHAIN_ACCOUNT)}, $secret)
1046
- `;
1047
- }
1048
- function windowsCredentialReadScript() {
1049
- return `
1050
- Add-Type -TypeDefinition @"
1051
- using System;
1052
- using System.ComponentModel;
1053
- using System.Runtime.InteropServices;
1054
-
1055
- public static class UserlandCredential {
1056
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
1057
- private struct Credential {
1058
- public UInt32 Flags;
1059
- public UInt32 Type;
1060
- public string TargetName;
1061
- public string Comment;
1062
- public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
1063
- public UInt32 CredentialBlobSize;
1064
- public IntPtr CredentialBlob;
1065
- public UInt32 Persist;
1066
- public UInt32 AttributeCount;
1067
- public IntPtr Attributes;
1068
- public string TargetAlias;
1069
- public string UserName;
1070
- }
1071
-
1072
- [DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
1073
- private static extern bool CredRead(string target, UInt32 type, UInt32 reservedFlag, out IntPtr credentialPtr);
1074
-
1075
- [DllImport("Advapi32.dll", SetLastError = true)]
1076
- private static extern void CredFree(IntPtr buffer);
1077
-
1078
- public static string Read(string target) {
1079
- IntPtr credentialPtr;
1080
- if (!CredRead(target, 1, 0, out credentialPtr)) {
1081
- int error = Marshal.GetLastWin32Error();
1082
- if (error == 1168) {
1083
- Environment.Exit(2);
1084
- }
1085
- throw new Win32Exception(error);
1086
- }
1087
-
1088
- try {
1089
- Credential credential = (Credential)Marshal.PtrToStructure(credentialPtr, typeof(Credential));
1090
- return Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
1091
- } finally {
1092
- CredFree(credentialPtr);
1093
- }
1094
- }
1095
- }
1096
- "@
1097
- [Console]::Out.Write([UserlandCredential]::Read(${JSON.stringify(KEYCHAIN_SERVICE)}))
1098
- `;
1099
- }
1100
967
  function parseOptions(args) {
1101
968
  const options = {};
1102
969
  for (let index = 0; index < args.length; index += 1) {
@@ -1120,11 +987,8 @@ function parseAuthOptions(args) {
1120
987
  const options = { save: true };
1121
988
  for (let index = 0; index < args.length; index += 1) {
1122
989
  const arg = args[index];
1123
- if (arg === "--username") {
1124
- options.username = args[++index];
1125
- }
1126
- else if (arg === "--password") {
1127
- options.password = args[++index];
990
+ if (arg === "--username" || arg === "--password") {
991
+ throw new Error("Userland platform auth is passwordless. Run `userland login` without username/password flags.");
1128
992
  }
1129
993
  else if (arg === "--email") {
1130
994
  options.email = args[++index];
@@ -1135,6 +999,24 @@ function parseAuthOptions(args) {
1135
999
  else if (arg === "--no-save") {
1136
1000
  options.save = false;
1137
1001
  }
1002
+ else if (arg === "--save=false") {
1003
+ options.save = false;
1004
+ }
1005
+ else if (arg === "--no-browser") {
1006
+ options.noBrowser = true;
1007
+ }
1008
+ else if (arg === "--api-base-url") {
1009
+ options.apiBaseUrl = args[++index];
1010
+ }
1011
+ else if (arg === "--console-url") {
1012
+ options.consoleUrl = args[++index];
1013
+ }
1014
+ else if (arg === "--account") {
1015
+ options.account = args[++index];
1016
+ }
1017
+ else if (arg === "--revoke") {
1018
+ options.revoke = true;
1019
+ }
1138
1020
  else {
1139
1021
  throw new Error(`Unknown option: ${arg}`);
1140
1022
  }
@@ -1269,48 +1151,6 @@ async function promptLine(prompt) {
1269
1151
  readline.close();
1270
1152
  }
1271
1153
  }
1272
- async function promptPassword(prompt) {
1273
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
1274
- return await promptRequired(prompt);
1275
- }
1276
- process.stdout.write(prompt);
1277
- process.stdin.setRawMode(true);
1278
- process.stdin.resume();
1279
- process.stdin.setEncoding("utf8");
1280
- return await new Promise((resolve, reject) => {
1281
- let value = "";
1282
- const cleanup = () => {
1283
- process.stdin.setRawMode(false);
1284
- process.stdin.off("data", onData);
1285
- };
1286
- const onData = (chunk) => {
1287
- for (const char of chunk) {
1288
- if (char === "\u0003") {
1289
- cleanup();
1290
- process.stdout.write("\n");
1291
- reject(new Error("Interrupted."));
1292
- return;
1293
- }
1294
- if (char === "\r" || char === "\n" || char === "\u0004") {
1295
- cleanup();
1296
- process.stdout.write("\n");
1297
- if (!value) {
1298
- reject(new Error("Password is required."));
1299
- return;
1300
- }
1301
- resolve(value);
1302
- return;
1303
- }
1304
- if (char === "\u007f") {
1305
- value = value.slice(0, -1);
1306
- continue;
1307
- }
1308
- value += char;
1309
- }
1310
- };
1311
- process.stdin.on("data", onData);
1312
- });
1313
- }
1314
1154
  function objectValue(value) {
1315
1155
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
1316
1156
  }
@@ -1450,10 +1290,11 @@ function isHelpCommand(command) {
1450
1290
  function usage(exitCode) {
1451
1291
  const message = `Usage:
1452
1292
  userland [--help]
1453
- userland signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
1454
- userland login [--username <username>] [--password <password>] [--no-save]
1293
+ userland signup [--no-browser] [--email <email>] [--api-base-url <url>] [--console-url <url>] [--no-save]
1294
+ userland login [--no-browser] [--email <email>] [--api-base-url <url>] [--console-url <url>] [--no-save]
1455
1295
  userland auth status
1456
- userland auth save-key --username <username> --api-key <api-key> [--password <password>]
1296
+ userland auth save-key --api-key <api-key> [--account <account-id>] [--api-base-url <url>] [--console-url <url>]
1297
+ userland auth logout [--revoke]
1457
1298
  userland accounts list
1458
1299
  userland accounts use <account-id>
1459
1300
  userland accounts status [--account <account-id>]
@@ -1485,8 +1326,8 @@ function usage(exitCode) {
1485
1326
  userland ops routes enable <route-id> [--reason <text>]
1486
1327
 
1487
1328
  Aliases:
1488
- userland auth signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
1489
- userland auth login [--username <username>] [--password <password>] [--no-save]
1329
+ userland auth signup [--no-browser] [--email <email>] [--no-save]
1330
+ userland auth login [--no-browser] [--email <email>] [--no-save]
1490
1331
  userland publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
1491
1332
  userland releases <app-id> [--account <account-id>]
1492
1333
  userland versions <app-id> [--account <account-id>]
@@ -1494,7 +1335,7 @@ Aliases:
1494
1335
  Credentials:
1495
1336
  Commands use USERLAND_API_KEY first, then ~/.userland/credentials.json for API keys.
1496
1337
  App commands use --account, then USERLAND_ACCOUNT_ID, then saved account_id when set.
1497
- Account username and password are stored in the OS keychain.
1338
+ Login and signup use browser device authorization and save only API-key credentials locally.
1498
1339
 
1499
1340
  Docs:
1500
1341
  https://docs.userland.fun/reference/cli
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@userland.fun/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Userland command-line tools for publishing and operating apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",