@userland.fun/cli 0.1.0 → 0.1.2

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 +10 -0
  2. package/dist/index.js +135 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -21,8 +21,12 @@ userland signup --username <username>
21
21
  userland login --username <username>
22
22
  userland auth status
23
23
  userland auth save-key --username <username> --api-key <api-key>
24
+ userland accounts list
25
+ userland accounts use <account-id>
24
26
  userland apps publish examples/<example-slug>
27
+ userland apps publish examples/<example-slug> --account <account-id>
25
28
  userland apps list
29
+ USERLAND_ACCOUNT_ID=<account-id> userland apps list
26
30
  userland apps releases <app-id>
27
31
  userland apps rollback <app-id> <release-id>
28
32
  userland apps secrets set <app-id> <NAME> --value <value>
@@ -36,8 +40,12 @@ npm run userland -- signup --username <username>
36
40
  npm run userland -- login --username <username>
37
41
  npm run userland -- auth status
38
42
  npm run userland -- auth save-key --username <username> --api-key <api-key>
43
+ npm run userland -- accounts list
44
+ npm run userland -- accounts use <account-id>
39
45
  npm run userland -- apps publish examples/<example-slug>
46
+ npm run userland -- apps publish examples/<example-slug> --account <account-id>
40
47
  npm run userland -- apps list
48
+ npm run userland -- apps list --account <account-id>
41
49
  npm run userland -- apps releases <app-id>
42
50
  npm run userland -- apps rollback <app-id> <release-id>
43
51
  npm run userland -- apps secrets set <app-id> <NAME> --value <value>
@@ -46,6 +54,8 @@ npm run userland -- apps events <app-id>
46
54
 
47
55
  `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.
48
56
 
57
+ 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.
58
+
49
59
  ## Validation
50
60
 
51
61
  Build and inspect the publish tarball:
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@ const KEYCHAIN_SERVICE = "fun.userland.cli";
9
9
  const KEYCHAIN_ACCOUNT = "default";
10
10
  async function main() {
11
11
  const [command, ...args] = process.argv.slice(2);
12
+ if (isHelpCommand(command)) {
13
+ usage(0);
14
+ }
12
15
  if (command === "apps") {
13
16
  await appsCommand(args);
14
17
  return;
@@ -17,6 +20,10 @@ async function main() {
17
20
  await authCommand(args);
18
21
  return;
19
22
  }
23
+ if (command === "accounts") {
24
+ await accountsCommand(args);
25
+ return;
26
+ }
20
27
  if (command === "signup") {
21
28
  await signupCommand(args);
22
29
  return;
@@ -42,7 +49,7 @@ async function appsCommand(args) {
42
49
  return;
43
50
  }
44
51
  if (subcommand === "list") {
45
- await listAppsCommand();
52
+ await listAppsCommand(rest);
46
53
  return;
47
54
  }
48
55
  if (subcommand === "releases") {
@@ -83,6 +90,18 @@ async function authCommand(args) {
83
90
  }
84
91
  usage(1);
85
92
  }
93
+ async function accountsCommand(args) {
94
+ const [subcommand, ...rest] = args;
95
+ if (subcommand === "list") {
96
+ await listAccountsCommand();
97
+ return;
98
+ }
99
+ if (subcommand === "use") {
100
+ await useAccountCommand(rest);
101
+ return;
102
+ }
103
+ usage(1);
104
+ }
86
105
  async function signupCommand(args) {
87
106
  const options = parseAuthOptions(args);
88
107
  const username = options.username ?? (await promptRequired("Username: "));
@@ -99,7 +118,8 @@ async function signupCommand(args) {
99
118
  await saveAccountCredentials({ username: response.username, password });
100
119
  const filePath = await saveCredentials({
101
120
  api_key: response.api_key,
102
- api_base_url: await apiBaseUrl()
121
+ api_base_url: await apiBaseUrl(),
122
+ account_id: response.account_id ?? null
103
123
  });
104
124
  console.log(`Created Userland account ${response.username}`);
105
125
  console.log(`Saved API key to ${filePath}`);
@@ -119,10 +139,13 @@ async function loginCommand(args) {
119
139
  body: JSON.stringify({ username, password })
120
140
  });
121
141
  if (options.save !== false) {
142
+ const baseUrl = await apiBaseUrl();
143
+ const accountId = response.account_id ?? (await discoverDefaultAccountId(response.api_key, baseUrl).catch(() => undefined));
122
144
  await saveAccountCredentials({ username, password });
123
145
  const filePath = await saveCredentials({
124
146
  api_key: response.api_key,
125
- api_base_url: await apiBaseUrl()
147
+ api_base_url: baseUrl,
148
+ account_id: accountId ?? null
126
149
  });
127
150
  console.log(`Saved API key to ${filePath}`);
128
151
  console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
@@ -135,10 +158,16 @@ async function authStatusCommand() {
135
158
  const account = await readAccountCredentials();
136
159
  const filePath = credentialsPath();
137
160
  const apiKeySource = process.env.USERLAND_API_KEY ? "env" : credentials?.api_key ? "file" : "missing";
161
+ const selectedAccountId = process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
162
+ const accountSource = process.env.USERLAND_ACCOUNT_ID ? "env" : credentials?.account_id ? "file" : apiKeySource === "missing" ? "missing" : "default";
138
163
  console.log(`api_base_url=${await apiBaseUrl(credentials)}`);
139
164
  console.log(`api_key=${apiKeySource}`);
140
165
  console.log(`credentials_file=${filePath}`);
141
- console.log(`account=${account ? "keychain" : "missing"}`);
166
+ console.log(`account=${accountSource}`);
167
+ if (selectedAccountId) {
168
+ console.log(`account_id=${selectedAccountId}`);
169
+ }
170
+ console.log(`account_login=${account ? "keychain" : "missing"}`);
142
171
  if (account?.username) {
143
172
  console.log(`username=${account.username}`);
144
173
  }
@@ -150,11 +179,30 @@ async function saveKeyCommand(args) {
150
179
  await saveAccountCredentials({ username, password: options.password });
151
180
  const filePath = await saveCredentials({
152
181
  api_key: apiKey,
153
- api_base_url: await apiBaseUrl()
182
+ api_base_url: await apiBaseUrl(),
183
+ account_id: null
154
184
  });
155
185
  console.log(`Saved API key to ${filePath}`);
156
186
  console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
157
187
  }
188
+ async function listAccountsCommand() {
189
+ const response = await apiFetch("/v0/accounts", {
190
+ method: "GET"
191
+ });
192
+ for (const account of response.accounts) {
193
+ console.log(`${account.account_id}\t${account.role}\t${account.name}`);
194
+ }
195
+ console.log(`default_account_id=${response.default_account_id}`);
196
+ }
197
+ async function useAccountCommand(args) {
198
+ const accountId = args[0];
199
+ if (!accountId) {
200
+ usage(1);
201
+ }
202
+ const filePath = await saveCredentials({ account_id: accountId });
203
+ console.log(`selected_account_id=${accountId}`);
204
+ console.log(`credentials_file=${filePath}`);
205
+ }
158
206
  async function publishCommand(args) {
159
207
  const dir = args[0];
160
208
  const options = parseOptions(args.slice(1));
@@ -165,7 +213,7 @@ async function publishCommand(args) {
165
213
  const response = await apiFetch(options.app ? `/v0/apps/${options.app}` : "/v0/apps", {
166
214
  method: "PUT",
167
215
  body: JSON.stringify(body)
168
- });
216
+ }, { accountId: options.account, accountScoped: true });
169
217
  console.log(`Published ${response.origin}`);
170
218
  console.log(`app_id=${response.app_id}`);
171
219
  console.log(`release_id=${response.release_id}`);
@@ -175,10 +223,11 @@ async function publishCommand(args) {
175
223
  console.log(`activation_reasons=${response.activation.reasons.join("; ")}`);
176
224
  }
177
225
  }
178
- async function listAppsCommand() {
226
+ async function listAppsCommand(args = []) {
227
+ const options = parseAccountOptions(args);
179
228
  const response = await apiFetch("/v0/apps", {
180
229
  method: "GET"
181
- });
230
+ }, { accountId: options.account, accountScoped: true });
182
231
  for (const app of response.apps) {
183
232
  console.log(`${app.app_id}\t${app.live_release_id ?? ""}\t${app.updated_at}\t${app.name}\t${app.origin}`);
184
233
  }
@@ -188,9 +237,10 @@ async function releasesCommand(args) {
188
237
  if (!appId) {
189
238
  usage(1);
190
239
  }
240
+ const options = parseAccountOptions(args.slice(1));
191
241
  const response = await apiFetch(`/v0/apps/${appId}/releases`, {
192
242
  method: "GET"
193
- });
243
+ }, { accountId: options.account, accountScoped: true });
194
244
  for (const release of response.releases) {
195
245
  const live = release.is_live ? " live" : "";
196
246
  console.log(`${release.release_id}${live}\t${release.activation_status}\t${release.created_at}\t${release.message ?? ""}`);
@@ -201,10 +251,11 @@ async function rollbackCommand(args) {
201
251
  if (!appId || !releaseId) {
202
252
  usage(1);
203
253
  }
254
+ const options = parseAccountOptions(args.slice(2));
204
255
  const response = await apiFetch(`/v0/apps/${appId}/rollback`, {
205
256
  method: "POST",
206
257
  body: JSON.stringify({ release_id: releaseId })
207
- });
258
+ }, { accountId: options.account, accountScoped: true });
208
259
  console.log(`Rolled back ${response.origin}`);
209
260
  console.log(`app_id=${response.app_id}`);
210
261
  console.log(`release_id=${response.release_id}`);
@@ -224,7 +275,7 @@ async function setSecretCommand(args) {
224
275
  const response = await apiFetch(`/v0/apps/${appId}/secrets/${name}`, {
225
276
  method: "PUT",
226
277
  body: JSON.stringify({ value })
227
- });
278
+ }, { accountId: options.account, accountScoped: true });
228
279
  console.log(`secret=${response.name}`);
229
280
  console.log(`present=${response.present}`);
230
281
  console.log(`updated_at=${response.updated_at}`);
@@ -247,7 +298,7 @@ async function eventsCommand(args) {
247
298
  const suffix = params.toString() ? `?${params.toString()}` : "";
248
299
  const response = await apiFetch(`/v0/apps/${appId}/events${suffix}`, {
249
300
  method: "GET"
250
- });
301
+ }, { accountId: options.account, accountScoped: true });
251
302
  for (const event of response.events) {
252
303
  console.log(`${event.created_at}\t${event.severity}\t${event.type}\t${event.release_id ?? ""}\t${event.message}`);
253
304
  }
@@ -363,24 +414,41 @@ async function walk(dir) {
363
414
  }));
364
415
  return files.flat();
365
416
  }
366
- async function apiFetch(apiPath, init) {
417
+ async function apiFetch(apiPath, init, options = {}) {
367
418
  const credentials = await readCredentials();
368
419
  const apiKey = process.env.USERLAND_API_KEY ?? credentials?.api_key;
369
420
  if (!apiKey) {
370
421
  throw new Error("USERLAND_API_KEY is required. Run `userland signup` or `userland login` to save credentials.");
371
422
  }
372
423
  const baseUrl = await apiBaseUrl(credentials);
424
+ const accountId = options.accountScoped ? selectedAccountId(options.accountId, credentials) : undefined;
425
+ const headers = {
426
+ authorization: `Bearer ${apiKey}`,
427
+ ...init.headers
428
+ };
429
+ if (accountId) {
430
+ headers["x-userland-account-id"] = accountId;
431
+ }
373
432
  return await requestJson(baseUrl, apiPath, {
374
433
  ...init,
375
- headers: {
376
- authorization: `Bearer ${apiKey}`,
377
- ...init.headers
378
- }
434
+ headers
379
435
  });
380
436
  }
381
437
  async function unauthenticatedApiFetch(apiPath, init) {
382
438
  return await requestJson(await apiBaseUrl(), apiPath, init);
383
439
  }
440
+ async function discoverDefaultAccountId(apiKey, baseUrl) {
441
+ const response = await requestJson(baseUrl, "/v0/accounts", {
442
+ method: "GET",
443
+ headers: {
444
+ authorization: `Bearer ${apiKey}`
445
+ }
446
+ });
447
+ return response.default_account_id;
448
+ }
449
+ function selectedAccountId(explicitAccountId, credentials) {
450
+ return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
451
+ }
384
452
  async function requestJson(baseUrl, apiPath, init) {
385
453
  const response = await fetch(`${baseUrl.replace(/\/$/u, "")}${apiPath}`, {
386
454
  ...init,
@@ -420,6 +488,7 @@ async function readCredentials() {
420
488
  }
421
489
  const credentials = parsed;
422
490
  return {
491
+ account_id: stringValue(credentials.account_id),
423
492
  api_base_url: stringValue(credentials.api_base_url),
424
493
  api_key: stringValue(credentials.api_key),
425
494
  updated_at: stringValue(credentials.updated_at)
@@ -428,12 +497,15 @@ async function readCredentials() {
428
497
  async function saveCredentials(update) {
429
498
  const filePath = credentialsPath();
430
499
  const existing = (await readCredentials()) ?? {};
431
- const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
500
+ const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined && value !== null));
432
501
  const credentials = {
433
502
  ...existing,
434
503
  ...sanitizedUpdate,
435
504
  updated_at: new Date().toISOString()
436
505
  };
506
+ if (update.account_id === null) {
507
+ delete credentials.account_id;
508
+ }
437
509
  const dir = path.dirname(filePath);
438
510
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
439
511
  await fs.chmod(dir, 0o700).catch(() => undefined);
@@ -706,6 +778,9 @@ function parseOptions(args) {
706
778
  else if (arg === "--message") {
707
779
  options.message = args[++index];
708
780
  }
781
+ else if (arg === "--account") {
782
+ options.account = args[++index];
783
+ }
709
784
  else {
710
785
  throw new Error(`Unknown option: ${arg}`);
711
786
  }
@@ -744,6 +819,9 @@ function parseSecretSetOptions(args) {
744
819
  if (arg === "--value") {
745
820
  options.value = args[++index];
746
821
  }
822
+ else if (arg === "--account") {
823
+ options.account = args[++index];
824
+ }
747
825
  else {
748
826
  throw new Error(`Unknown option: ${arg}`);
749
827
  }
@@ -766,6 +844,22 @@ function parseEventsOptions(args) {
766
844
  else if (arg === "--limit") {
767
845
  options.limit = args[++index];
768
846
  }
847
+ else if (arg === "--account") {
848
+ options.account = args[++index];
849
+ }
850
+ else {
851
+ throw new Error(`Unknown option: ${arg}`);
852
+ }
853
+ }
854
+ return options;
855
+ }
856
+ function parseAccountOptions(args) {
857
+ const options = {};
858
+ for (let index = 0; index < args.length; index += 1) {
859
+ const arg = args[index];
860
+ if (arg === "--account") {
861
+ options.account = args[++index];
862
+ }
769
863
  else {
770
864
  throw new Error(`Unknown option: ${arg}`);
771
865
  }
@@ -879,32 +973,45 @@ function errorMessage(body) {
879
973
  }
880
974
  return message ?? code;
881
975
  }
976
+ function isHelpCommand(command) {
977
+ return command === "--help" || command === "-h" || command === "help";
978
+ }
882
979
  function usage(exitCode) {
883
- console.error(`Usage:
980
+ const message = `Usage:
981
+ userland [--help]
884
982
  userland signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
885
983
  userland login [--username <username>] [--password <password>] [--no-save]
886
984
  userland auth status
887
985
  userland auth save-key --username <username> --api-key <api-key> [--password <password>]
888
- userland apps publish <dir> [--app <app-id>] [--message <message>]
889
- userland apps list
890
- userland apps releases <app-id>
891
- userland apps rollback <app-id> <release-id>
892
- userland apps secrets set <app-id> <NAME> [--value <value>]
893
- userland apps events <app-id> [--type <event-type>] [--severity <level>] [--release <release-id>] [--limit <n>]
986
+ userland accounts list
987
+ userland accounts use <account-id>
988
+ userland apps publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
989
+ userland apps list [--account <account-id>]
990
+ userland apps releases <app-id> [--account <account-id>]
991
+ userland apps rollback <app-id> <release-id> [--account <account-id>]
992
+ userland apps secrets set <app-id> <NAME> [--value <value>] [--account <account-id>]
993
+ userland apps events <app-id> [--type <event-type>] [--severity <level>] [--release <release-id>] [--limit <n>] [--account <account-id>]
894
994
 
895
995
  Aliases:
896
996
  userland auth signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
897
997
  userland auth login [--username <username>] [--password <password>] [--no-save]
898
- userland publish <dir> [--app <app-id>] [--message <message>]
899
- userland releases <app-id>
998
+ userland publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
999
+ userland releases <app-id> [--account <account-id>]
900
1000
 
901
1001
  Credentials:
902
1002
  Commands use USERLAND_API_KEY first, then ~/.userland/credentials.json for API keys.
1003
+ App commands use --account, then USERLAND_ACCOUNT_ID, then saved account_id when set.
903
1004
  Account username and password are stored in the OS keychain.
904
1005
 
905
1006
  Docs:
906
1007
  https://docs.userland.fun/reference/cli
907
- https://docs.userland.fun/guides/troubleshooting`);
1008
+ https://docs.userland.fun/guides/troubleshooting`;
1009
+ if (exitCode === 0) {
1010
+ console.log(message);
1011
+ }
1012
+ else {
1013
+ console.error(message);
1014
+ }
908
1015
  process.exit(exitCode);
909
1016
  }
910
1017
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@userland.fun/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Userland command-line tools for publishing and operating apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",