@userland.fun/cli 0.2.0 → 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 +15 -7
  2. package/dist/index.js +203 -369
  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
@@ -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)) {
@@ -108,6 +108,10 @@ async function authCommand(args) {
108
108
  await saveKeyCommand(rest);
109
109
  return;
110
110
  }
111
+ if (subcommand === "logout") {
112
+ await logoutCommand(rest);
113
+ return;
114
+ }
111
115
  usage(1);
112
116
  }
113
117
  async function accountsCommand(args) {
@@ -191,86 +195,132 @@ async function opsCommand(args) {
191
195
  }
192
196
  async function signupCommand(args) {
193
197
  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}`);
198
+ await deviceLoginCommand(options, { signupAlias: true });
218
199
  }
219
200
  async function loginCommand(args) {
220
201
  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", {
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", {
225
209
  method: "POST",
226
- body: JSON.stringify({ username, password })
210
+ body: JSON.stringify({
211
+ client: "userland-cli",
212
+ client_version: CLI_VERSION,
213
+ requested_capability: "api_key"
214
+ })
227
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);
228
239
  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
240
  const filePath = await saveCredentials({
233
241
  api_key: response.api_key,
242
+ api_key_id: response.api_key_id ?? null,
234
243
  api_base_url: baseUrl,
235
- account_id: accountId ?? null
244
+ console_url: consoleUrl,
245
+ username: response.username ?? null,
246
+ account_id: response.default_account_id ?? null
236
247
  });
237
248
  console.log(`Saved API key to ${filePath}`);
238
- 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
+ }
239
255
  return;
240
256
  }
241
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
+ }
242
264
  }
243
265
  async function authStatusCommand() {
244
266
  const credentials = await readCredentials();
245
- const account = await readAccountCredentials();
246
267
  const filePath = credentialsPath();
247
268
  const apiKeySource = process.env.USERLAND_API_KEY ? "env" : credentials?.api_key ? "file" : "missing";
248
269
  const selectedAccountId = process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
249
270
  const accountSource = process.env.USERLAND_ACCOUNT_ID ? "env" : credentials?.account_id ? "file" : apiKeySource === "missing" ? "missing" : "default";
250
271
  console.log(`api_base_url=${await apiBaseUrl(credentials)}`);
272
+ console.log(`console_url=${await consoleBaseUrl(credentials)}`);
251
273
  console.log(`api_key=${apiKeySource}`);
252
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
+ }
253
278
  console.log(`account=${accountSource}`);
254
279
  if (selectedAccountId) {
255
280
  console.log(`account_id=${selectedAccountId}`);
256
281
  }
257
- console.log(`account_login=${account ? "keychain" : "missing"}`);
258
- if (account?.username) {
259
- console.log(`username=${account.username}`);
282
+ if (apiKeySource === "file" && credentials?.username) {
283
+ console.log(`username=${credentials.username}`);
260
284
  }
261
285
  }
262
286
  async function saveKeyCommand(args) {
263
287
  const options = parseAuthOptions(args);
264
- const username = options.username ?? (await promptRequired("Username: "));
265
288
  const apiKey = options.apiKey ?? (await promptRequired("API key: "));
266
- await saveAccountCredentials({ username, password: options.password });
289
+ const credentials = await readCredentials();
267
290
  const filePath = await saveCredentials({
268
291
  api_key: apiKey,
269
- api_base_url: await apiBaseUrl(),
270
- 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
271
297
  });
272
298
  console.log(`Saved API key to ${filePath}`);
273
- 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}`);
274
324
  }
275
325
  async function listAccountsCommand() {
276
326
  const response = await apiFetch("/v0/accounts", {
@@ -763,20 +813,50 @@ async function apiFetch(apiPath, init, options = {}) {
763
813
  headers
764
814
  });
765
815
  }
766
- async function unauthenticatedApiFetch(apiPath, init) {
767
- return await requestJson(await apiBaseUrl(), apiPath, init);
816
+ function selectedAccountId(explicitAccountId, credentials) {
817
+ return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
768
818
  }
769
- async function discoverDefaultAccountId(apiKey, baseUrl) {
770
- const response = await requestJson(baseUrl, "/v0/accounts", {
771
- method: "GET",
772
- headers: {
773
- 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;
774
829
  }
775
- });
776
- 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.");
777
842
  }
778
- function selectedAccountId(explicitAccountId, credentials) {
779
- 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));
780
860
  }
781
861
  async function requestJson(baseUrl, apiPath, init) {
782
862
  const response = await fetch(`${baseUrl.replace(/\/$/u, "")}${apiPath}`, {
@@ -794,8 +874,38 @@ async function requestJson(baseUrl, apiPath, init) {
794
874
  }
795
875
  return body;
796
876
  }
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;
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
+ });
799
909
  }
800
910
  function credentialsPath() {
801
911
  return process.env.USERLAND_CREDENTIALS_FILE ?? path.join(os.homedir(), ".userland", "credentials.json");
@@ -820,7 +930,10 @@ async function readCredentials() {
820
930
  account_id: stringValue(credentials.account_id),
821
931
  api_base_url: stringValue(credentials.api_base_url),
822
932
  api_key: stringValue(credentials.api_key),
823
- 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)
824
937
  };
825
938
  }
826
939
  async function saveCredentials(update) {
@@ -832,8 +945,10 @@ async function saveCredentials(update) {
832
945
  ...sanitizedUpdate,
833
946
  updated_at: new Date().toISOString()
834
947
  };
835
- if (update.account_id === null) {
836
- delete credentials.account_id;
948
+ for (const [key, value] of Object.entries(update)) {
949
+ if (value === null) {
950
+ delete credentials[key];
951
+ }
837
952
  }
838
953
  const dir = path.dirname(filePath);
839
954
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
@@ -842,261 +957,6 @@ async function saveCredentials(update) {
842
957
  await fs.chmod(filePath, 0o600).catch(() => undefined);
843
958
  return filePath;
844
959
  }
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
960
  function parseOptions(args) {
1101
961
  const options = {};
1102
962
  for (let index = 0; index < args.length; index += 1) {
@@ -1120,11 +980,8 @@ function parseAuthOptions(args) {
1120
980
  const options = { save: true };
1121
981
  for (let index = 0; index < args.length; index += 1) {
1122
982
  const arg = args[index];
1123
- if (arg === "--username") {
1124
- options.username = args[++index];
1125
- }
1126
- else if (arg === "--password") {
1127
- 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.");
1128
985
  }
1129
986
  else if (arg === "--email") {
1130
987
  options.email = args[++index];
@@ -1135,6 +992,24 @@ function parseAuthOptions(args) {
1135
992
  else if (arg === "--no-save") {
1136
993
  options.save = false;
1137
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
+ }
1138
1013
  else {
1139
1014
  throw new Error(`Unknown option: ${arg}`);
1140
1015
  }
@@ -1269,48 +1144,6 @@ async function promptLine(prompt) {
1269
1144
  readline.close();
1270
1145
  }
1271
1146
  }
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
1147
  function objectValue(value) {
1315
1148
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
1316
1149
  }
@@ -1450,10 +1283,11 @@ function isHelpCommand(command) {
1450
1283
  function usage(exitCode) {
1451
1284
  const message = `Usage:
1452
1285
  userland [--help]
1453
- userland signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
1454
- 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]
1455
1288
  userland auth status
1456
- 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]
1457
1291
  userland accounts list
1458
1292
  userland accounts use <account-id>
1459
1293
  userland accounts status [--account <account-id>]
@@ -1485,8 +1319,8 @@ function usage(exitCode) {
1485
1319
  userland ops routes enable <route-id> [--reason <text>]
1486
1320
 
1487
1321
  Aliases:
1488
- userland auth signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
1489
- 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]
1490
1324
  userland publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
1491
1325
  userland releases <app-id> [--account <account-id>]
1492
1326
  userland versions <app-id> [--account <account-id>]
@@ -1494,7 +1328,7 @@ Aliases:
1494
1328
  Credentials:
1495
1329
  Commands use USERLAND_API_KEY first, then ~/.userland/credentials.json for API keys.
1496
1330
  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.
1331
+ Login and signup use browser device authorization and save only API-key credentials locally.
1498
1332
 
1499
1333
  Docs:
1500
1334
  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.0",
4
4
  "description": "Userland command-line tools for publishing and operating apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",