@userland.fun/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/dist/index.js +120 -26
  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
@@ -17,6 +17,10 @@ async function main() {
17
17
  await authCommand(args);
18
18
  return;
19
19
  }
20
+ if (command === "accounts") {
21
+ await accountsCommand(args);
22
+ return;
23
+ }
20
24
  if (command === "signup") {
21
25
  await signupCommand(args);
22
26
  return;
@@ -42,7 +46,7 @@ async function appsCommand(args) {
42
46
  return;
43
47
  }
44
48
  if (subcommand === "list") {
45
- await listAppsCommand();
49
+ await listAppsCommand(rest);
46
50
  return;
47
51
  }
48
52
  if (subcommand === "releases") {
@@ -83,6 +87,18 @@ async function authCommand(args) {
83
87
  }
84
88
  usage(1);
85
89
  }
90
+ async function accountsCommand(args) {
91
+ const [subcommand, ...rest] = args;
92
+ if (subcommand === "list") {
93
+ await listAccountsCommand();
94
+ return;
95
+ }
96
+ if (subcommand === "use") {
97
+ await useAccountCommand(rest);
98
+ return;
99
+ }
100
+ usage(1);
101
+ }
86
102
  async function signupCommand(args) {
87
103
  const options = parseAuthOptions(args);
88
104
  const username = options.username ?? (await promptRequired("Username: "));
@@ -99,7 +115,8 @@ async function signupCommand(args) {
99
115
  await saveAccountCredentials({ username: response.username, password });
100
116
  const filePath = await saveCredentials({
101
117
  api_key: response.api_key,
102
- api_base_url: await apiBaseUrl()
118
+ api_base_url: await apiBaseUrl(),
119
+ account_id: response.account_id ?? null
103
120
  });
104
121
  console.log(`Created Userland account ${response.username}`);
105
122
  console.log(`Saved API key to ${filePath}`);
@@ -119,10 +136,13 @@ async function loginCommand(args) {
119
136
  body: JSON.stringify({ username, password })
120
137
  });
121
138
  if (options.save !== false) {
139
+ const baseUrl = await apiBaseUrl();
140
+ const accountId = response.account_id ?? (await discoverDefaultAccountId(response.api_key, baseUrl).catch(() => undefined));
122
141
  await saveAccountCredentials({ username, password });
123
142
  const filePath = await saveCredentials({
124
143
  api_key: response.api_key,
125
- api_base_url: await apiBaseUrl()
144
+ api_base_url: baseUrl,
145
+ account_id: accountId ?? null
126
146
  });
127
147
  console.log(`Saved API key to ${filePath}`);
128
148
  console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
@@ -135,10 +155,16 @@ async function authStatusCommand() {
135
155
  const account = await readAccountCredentials();
136
156
  const filePath = credentialsPath();
137
157
  const apiKeySource = process.env.USERLAND_API_KEY ? "env" : credentials?.api_key ? "file" : "missing";
158
+ const selectedAccountId = process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
159
+ const accountSource = process.env.USERLAND_ACCOUNT_ID ? "env" : credentials?.account_id ? "file" : apiKeySource === "missing" ? "missing" : "default";
138
160
  console.log(`api_base_url=${await apiBaseUrl(credentials)}`);
139
161
  console.log(`api_key=${apiKeySource}`);
140
162
  console.log(`credentials_file=${filePath}`);
141
- console.log(`account=${account ? "keychain" : "missing"}`);
163
+ console.log(`account=${accountSource}`);
164
+ if (selectedAccountId) {
165
+ console.log(`account_id=${selectedAccountId}`);
166
+ }
167
+ console.log(`account_login=${account ? "keychain" : "missing"}`);
142
168
  if (account?.username) {
143
169
  console.log(`username=${account.username}`);
144
170
  }
@@ -150,11 +176,30 @@ async function saveKeyCommand(args) {
150
176
  await saveAccountCredentials({ username, password: options.password });
151
177
  const filePath = await saveCredentials({
152
178
  api_key: apiKey,
153
- api_base_url: await apiBaseUrl()
179
+ api_base_url: await apiBaseUrl(),
180
+ account_id: null
154
181
  });
155
182
  console.log(`Saved API key to ${filePath}`);
156
183
  console.log(`Saved account login to ${accountCredentialStoreLabel()}`);
157
184
  }
185
+ async function listAccountsCommand() {
186
+ const response = await apiFetch("/v0/accounts", {
187
+ method: "GET"
188
+ });
189
+ for (const account of response.accounts) {
190
+ console.log(`${account.account_id}\t${account.role}\t${account.name}`);
191
+ }
192
+ console.log(`default_account_id=${response.default_account_id}`);
193
+ }
194
+ async function useAccountCommand(args) {
195
+ const accountId = args[0];
196
+ if (!accountId) {
197
+ usage(1);
198
+ }
199
+ const filePath = await saveCredentials({ account_id: accountId });
200
+ console.log(`selected_account_id=${accountId}`);
201
+ console.log(`credentials_file=${filePath}`);
202
+ }
158
203
  async function publishCommand(args) {
159
204
  const dir = args[0];
160
205
  const options = parseOptions(args.slice(1));
@@ -165,7 +210,7 @@ async function publishCommand(args) {
165
210
  const response = await apiFetch(options.app ? `/v0/apps/${options.app}` : "/v0/apps", {
166
211
  method: "PUT",
167
212
  body: JSON.stringify(body)
168
- });
213
+ }, { accountId: options.account, accountScoped: true });
169
214
  console.log(`Published ${response.origin}`);
170
215
  console.log(`app_id=${response.app_id}`);
171
216
  console.log(`release_id=${response.release_id}`);
@@ -175,10 +220,11 @@ async function publishCommand(args) {
175
220
  console.log(`activation_reasons=${response.activation.reasons.join("; ")}`);
176
221
  }
177
222
  }
178
- async function listAppsCommand() {
223
+ async function listAppsCommand(args = []) {
224
+ const options = parseAccountOptions(args);
179
225
  const response = await apiFetch("/v0/apps", {
180
226
  method: "GET"
181
- });
227
+ }, { accountId: options.account, accountScoped: true });
182
228
  for (const app of response.apps) {
183
229
  console.log(`${app.app_id}\t${app.live_release_id ?? ""}\t${app.updated_at}\t${app.name}\t${app.origin}`);
184
230
  }
@@ -188,9 +234,10 @@ async function releasesCommand(args) {
188
234
  if (!appId) {
189
235
  usage(1);
190
236
  }
237
+ const options = parseAccountOptions(args.slice(1));
191
238
  const response = await apiFetch(`/v0/apps/${appId}/releases`, {
192
239
  method: "GET"
193
- });
240
+ }, { accountId: options.account, accountScoped: true });
194
241
  for (const release of response.releases) {
195
242
  const live = release.is_live ? " live" : "";
196
243
  console.log(`${release.release_id}${live}\t${release.activation_status}\t${release.created_at}\t${release.message ?? ""}`);
@@ -201,10 +248,11 @@ async function rollbackCommand(args) {
201
248
  if (!appId || !releaseId) {
202
249
  usage(1);
203
250
  }
251
+ const options = parseAccountOptions(args.slice(2));
204
252
  const response = await apiFetch(`/v0/apps/${appId}/rollback`, {
205
253
  method: "POST",
206
254
  body: JSON.stringify({ release_id: releaseId })
207
- });
255
+ }, { accountId: options.account, accountScoped: true });
208
256
  console.log(`Rolled back ${response.origin}`);
209
257
  console.log(`app_id=${response.app_id}`);
210
258
  console.log(`release_id=${response.release_id}`);
@@ -224,7 +272,7 @@ async function setSecretCommand(args) {
224
272
  const response = await apiFetch(`/v0/apps/${appId}/secrets/${name}`, {
225
273
  method: "PUT",
226
274
  body: JSON.stringify({ value })
227
- });
275
+ }, { accountId: options.account, accountScoped: true });
228
276
  console.log(`secret=${response.name}`);
229
277
  console.log(`present=${response.present}`);
230
278
  console.log(`updated_at=${response.updated_at}`);
@@ -247,7 +295,7 @@ async function eventsCommand(args) {
247
295
  const suffix = params.toString() ? `?${params.toString()}` : "";
248
296
  const response = await apiFetch(`/v0/apps/${appId}/events${suffix}`, {
249
297
  method: "GET"
250
- });
298
+ }, { accountId: options.account, accountScoped: true });
251
299
  for (const event of response.events) {
252
300
  console.log(`${event.created_at}\t${event.severity}\t${event.type}\t${event.release_id ?? ""}\t${event.message}`);
253
301
  }
@@ -363,24 +411,41 @@ async function walk(dir) {
363
411
  }));
364
412
  return files.flat();
365
413
  }
366
- async function apiFetch(apiPath, init) {
414
+ async function apiFetch(apiPath, init, options = {}) {
367
415
  const credentials = await readCredentials();
368
416
  const apiKey = process.env.USERLAND_API_KEY ?? credentials?.api_key;
369
417
  if (!apiKey) {
370
418
  throw new Error("USERLAND_API_KEY is required. Run `userland signup` or `userland login` to save credentials.");
371
419
  }
372
420
  const baseUrl = await apiBaseUrl(credentials);
421
+ const accountId = options.accountScoped ? selectedAccountId(options.accountId, credentials) : undefined;
422
+ const headers = {
423
+ authorization: `Bearer ${apiKey}`,
424
+ ...init.headers
425
+ };
426
+ if (accountId) {
427
+ headers["x-userland-account-id"] = accountId;
428
+ }
373
429
  return await requestJson(baseUrl, apiPath, {
374
430
  ...init,
375
- headers: {
376
- authorization: `Bearer ${apiKey}`,
377
- ...init.headers
378
- }
431
+ headers
379
432
  });
380
433
  }
381
434
  async function unauthenticatedApiFetch(apiPath, init) {
382
435
  return await requestJson(await apiBaseUrl(), apiPath, init);
383
436
  }
437
+ async function discoverDefaultAccountId(apiKey, baseUrl) {
438
+ const response = await requestJson(baseUrl, "/v0/accounts", {
439
+ method: "GET",
440
+ headers: {
441
+ authorization: `Bearer ${apiKey}`
442
+ }
443
+ });
444
+ return response.default_account_id;
445
+ }
446
+ function selectedAccountId(explicitAccountId, credentials) {
447
+ return explicitAccountId ?? process.env.USERLAND_ACCOUNT_ID ?? credentials?.account_id;
448
+ }
384
449
  async function requestJson(baseUrl, apiPath, init) {
385
450
  const response = await fetch(`${baseUrl.replace(/\/$/u, "")}${apiPath}`, {
386
451
  ...init,
@@ -420,6 +485,7 @@ async function readCredentials() {
420
485
  }
421
486
  const credentials = parsed;
422
487
  return {
488
+ account_id: stringValue(credentials.account_id),
423
489
  api_base_url: stringValue(credentials.api_base_url),
424
490
  api_key: stringValue(credentials.api_key),
425
491
  updated_at: stringValue(credentials.updated_at)
@@ -428,12 +494,15 @@ async function readCredentials() {
428
494
  async function saveCredentials(update) {
429
495
  const filePath = credentialsPath();
430
496
  const existing = (await readCredentials()) ?? {};
431
- const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined));
497
+ const sanitizedUpdate = Object.fromEntries(Object.entries(update).filter(([, value]) => value !== undefined && value !== null));
432
498
  const credentials = {
433
499
  ...existing,
434
500
  ...sanitizedUpdate,
435
501
  updated_at: new Date().toISOString()
436
502
  };
503
+ if (update.account_id === null) {
504
+ delete credentials.account_id;
505
+ }
437
506
  const dir = path.dirname(filePath);
438
507
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
439
508
  await fs.chmod(dir, 0o700).catch(() => undefined);
@@ -706,6 +775,9 @@ function parseOptions(args) {
706
775
  else if (arg === "--message") {
707
776
  options.message = args[++index];
708
777
  }
778
+ else if (arg === "--account") {
779
+ options.account = args[++index];
780
+ }
709
781
  else {
710
782
  throw new Error(`Unknown option: ${arg}`);
711
783
  }
@@ -744,6 +816,9 @@ function parseSecretSetOptions(args) {
744
816
  if (arg === "--value") {
745
817
  options.value = args[++index];
746
818
  }
819
+ else if (arg === "--account") {
820
+ options.account = args[++index];
821
+ }
747
822
  else {
748
823
  throw new Error(`Unknown option: ${arg}`);
749
824
  }
@@ -766,6 +841,22 @@ function parseEventsOptions(args) {
766
841
  else if (arg === "--limit") {
767
842
  options.limit = args[++index];
768
843
  }
844
+ else if (arg === "--account") {
845
+ options.account = args[++index];
846
+ }
847
+ else {
848
+ throw new Error(`Unknown option: ${arg}`);
849
+ }
850
+ }
851
+ return options;
852
+ }
853
+ function parseAccountOptions(args) {
854
+ const options = {};
855
+ for (let index = 0; index < args.length; index += 1) {
856
+ const arg = args[index];
857
+ if (arg === "--account") {
858
+ options.account = args[++index];
859
+ }
769
860
  else {
770
861
  throw new Error(`Unknown option: ${arg}`);
771
862
  }
@@ -885,21 +976,24 @@ function usage(exitCode) {
885
976
  userland login [--username <username>] [--password <password>] [--no-save]
886
977
  userland auth status
887
978
  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>]
979
+ userland accounts list
980
+ userland accounts use <account-id>
981
+ userland apps publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
982
+ userland apps list [--account <account-id>]
983
+ userland apps releases <app-id> [--account <account-id>]
984
+ userland apps rollback <app-id> <release-id> [--account <account-id>]
985
+ userland apps secrets set <app-id> <NAME> [--value <value>] [--account <account-id>]
986
+ userland apps events <app-id> [--type <event-type>] [--severity <level>] [--release <release-id>] [--limit <n>] [--account <account-id>]
894
987
 
895
988
  Aliases:
896
989
  userland auth signup [--username <username>] [--password <password>] [--email <email>] [--no-save]
897
990
  userland auth login [--username <username>] [--password <password>] [--no-save]
898
- userland publish <dir> [--app <app-id>] [--message <message>]
899
- userland releases <app-id>
991
+ userland publish <dir> [--app <app-id>] [--message <message>] [--account <account-id>]
992
+ userland releases <app-id> [--account <account-id>]
900
993
 
901
994
  Credentials:
902
995
  Commands use USERLAND_API_KEY first, then ~/.userland/credentials.json for API keys.
996
+ App commands use --account, then USERLAND_ACCOUNT_ID, then saved account_id when set.
903
997
  Account username and password are stored in the OS keychain.
904
998
 
905
999
  Docs:
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.1",
4
4
  "description": "Userland command-line tools for publishing and operating apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",