@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.
- package/README.md +15 -7
- package/dist/index.js +203 -369
- 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
|
@@ -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
|
|
9
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
289
|
+
const credentials = await readCredentials();
|
|
267
290
|
const filePath = await saveCredentials({
|
|
268
291
|
api_key: apiKey,
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
767
|
-
return
|
|
816
|
+
function selectedAccountId(explicitAccountId, credentials) {
|
|
817
|
+
return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
|
|
768
818
|
}
|
|
769
|
-
async function
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
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
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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 [--
|
|
1454
|
-
userland login [--
|
|
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 --
|
|
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 [--
|
|
1489
|
-
userland auth login [--
|
|
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
|
-
|
|
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
|