@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.
- package/README.md +15 -7
- package/dist/index.js +211 -370
- 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
|
|
21
|
-
userland login --
|
|
20
|
+
userland login
|
|
21
|
+
userland login --no-browser
|
|
22
|
+
userland signup
|
|
22
23
|
userland auth status
|
|
23
|
-
userland auth save-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 --
|
|
49
|
-
npm run userland -- login --
|
|
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 --
|
|
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
|
-
`
|
|
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
|
|
9
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
296
|
+
const credentials = await readCredentials();
|
|
267
297
|
const filePath = await saveCredentials({
|
|
268
298
|
api_key: apiKey,
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
767
|
-
return
|
|
823
|
+
function selectedAccountId(explicitAccountId, credentials) {
|
|
824
|
+
return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
|
|
768
825
|
}
|
|
769
|
-
async function
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
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
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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 [--
|
|
1454
|
-
userland login [--
|
|
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 --
|
|
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 [--
|
|
1489
|
-
userland auth login [--
|
|
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
|
-
|
|
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
|