nimblybase 0.1.4 → 0.1.6

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 +17 -1
  2. package/dist/index.js +163 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,13 +30,29 @@ keychain on macOS, and otherwise in
30
30
  `~/.config/nimblybase/credentials.json` with permissions `0600`. `nimbly
31
31
  login` always says which one it used.
32
32
 
33
- For a machine without a browser, create an access token in the dashboard
33
+ For a machine without a local browser, run `nimbly login --device`. Open the
34
+ displayed verification page on another device and enter the short code. The
35
+ dashboard shows the requesting client and approved workspace/scopes. The CLI
36
+ waits at the server's polling interval, backs off on slow-down, and stops on
37
+ denial or expiry. A failed attempt keeps your previous login. Device sign-in
38
+ still requires a person; it is not unattended CI authentication.
39
+
40
+ For an explicit pasted-token fallback, create an access token in the dashboard
34
41
  under **Access tokens** and run `nimbly login --token <token>`. Omitting the
35
42
  value prompts without echoing it.
36
43
 
37
44
  In CI, set `NIMBLYBASE_TOKEN` instead. It takes precedence over anything
38
45
  stored and is never written to disk.
39
46
 
47
+ Stored browser grants and newly saved access tokens stay bound to the API used
48
+ at login. A repository link, `--api`, or environment setting cannot retarget that
49
+ credential. To switch servers, explicitly run `nimbly login --api <destination>`;
50
+ the previous login is kept if verification fails. Older bare tokens remain usable
51
+ on the original UAT default until you log in again to bind another destination.
52
+ An injected CI token requires an explicit `NIMBLYBASE_API_URL`/`--api` when a
53
+ repository selects a different API. It never refreshes a separate personal login.
54
+ Use HTTPS; local development may use literal `http://127.0.0.1:<port>` or `[::1]`.
55
+
40
56
  ## Requirements before creating
41
57
 
42
58
  For a new empty application, choose `--recipe static-d1` explicitly when a static
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ function parse(argv) {
68
68
  spa: { type: "boolean" },
69
69
  build: { type: "boolean" },
70
70
  "token-only": { type: "boolean" },
71
+ device: { type: "boolean" },
71
72
  off: { type: "boolean" },
72
73
  using: { type: "string" },
73
74
  check: { type: "string" },
@@ -140,6 +141,7 @@ function parse(argv) {
140
141
  spa: v.spa === true ? true : void 0,
141
142
  build: v.build === true ? true : void 0,
142
143
  tokenOnly: v["token-only"] === true ? true : void 0,
144
+ device: v.device === true ? true : void 0,
143
145
  off: v.off === true ? true : void 0,
144
146
  using: v.using,
145
147
  check: v.check,
@@ -182,7 +184,8 @@ USAGE
182
184
  nimbly <command> [options]
183
185
 
184
186
  GETTING STARTED
185
- login Sign in with an access token from the dashboard
187
+ login Sign in through your browser; --device for another machine
188
+ --token-only is the explicit pasted-PAT fallback
186
189
  init [name] Create or explicitly choose a project for this repo
187
190
  describe Show what the linked project's backend can do
188
191
  architect [requirements] Inspect this repo and propose a reviewable architecture
@@ -370,19 +373,31 @@ function when(iso) {
370
373
 
371
374
  // src/api.ts
372
375
  var DEFAULT_API_URL = "https://api-uat.nimblybase.com";
373
- var VERSION = "0.1.4";
376
+ var VERSION = "0.1.6";
374
377
  function dashboardUrl(api) {
375
378
  const match = /^https:\/\/api(-[a-z0-9]+)?\.(.+)$/.exec(api);
376
379
  return match ? `https://app${match[1] ?? ""}.${match[2]}` : null;
377
380
  }
378
381
  function apiUrl(override) {
379
- return (override ?? process.env.NIMBLYBASE_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
382
+ const raw = override ?? process.env.NIMBLYBASE_API_URL ?? DEFAULT_API_URL;
383
+ let parsed;
384
+ try {
385
+ parsed = new URL(raw);
386
+ } catch {
387
+ throw new CliError("Invalid API address.", EXIT.usage);
388
+ }
389
+ const loopback = ["127.0.0.1", "[::1]"].includes(parsed.hostname);
390
+ if (parsed.username || parsed.password || parsed.search || parsed.hash || !/^\/*$/.test(parsed.pathname) || parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
391
+ throw new CliError("The API must be an HTTPS origin without credentials, path, query or fragment. HTTP is allowed only on a literal loopback address.", EXIT.usage);
392
+ }
393
+ return parsed.origin;
380
394
  }
381
395
  async function request(base, token, path, init2 = {}) {
382
396
  let res;
383
397
  try {
384
398
  res = await fetch(`${base}${path}`, {
385
399
  ...init2,
400
+ redirect: "error",
386
401
  headers: {
387
402
  authorization: `Bearer ${token}`,
388
403
  ...init2.body ? { "content-type": "application/json" } : {},
@@ -638,8 +653,9 @@ function pkcePair() {
638
653
  return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) };
639
654
  }
640
655
  async function discover(issuer, fetchImpl = fetch) {
641
- const url = new URL("/.well-known/oauth-authorization-server", issuer).toString();
642
- const res = await fetchImpl(url);
656
+ const origin = apiUrl(issuer);
657
+ const url = new URL("/.well-known/oauth-authorization-server", origin).toString();
658
+ const res = await fetchImpl(url, { redirect: "error", signal: AbortSignal.timeout(3e4) });
643
659
  if (!res.ok) {
644
660
  throw new Error(`${issuer} does not advertise an authorization server (${res.status} from ${url})`);
645
661
  }
@@ -647,11 +663,23 @@ async function discover(issuer, fetchImpl = fetch) {
647
663
  if (!doc.authorization_endpoint || !doc.token_endpoint) {
648
664
  throw new Error("the authorization server metadata is missing an endpoint this flow needs");
649
665
  }
666
+ for (const endpoint of [doc.token_endpoint, doc.registration_endpoint, doc.device_authorization_endpoint].filter(Boolean)) {
667
+ const target = new URL(endpoint);
668
+ if (target.origin !== origin || target.username || target.password || target.hash) {
669
+ throw new Error("the authorization server advertises a credential endpoint outside its API origin");
670
+ }
671
+ }
672
+ const authorize = new URL(doc.authorization_endpoint);
673
+ if (authorize.username || authorize.password || authorize.hash || authorize.protocol !== "https:" && !(authorize.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(authorize.hostname))) {
674
+ throw new Error("the authorization endpoint must use HTTPS, or literal loopback HTTP");
675
+ }
650
676
  return doc;
651
677
  }
652
678
  async function registerClient(registrationEndpoint, fetchImpl = fetch) {
653
679
  const res = await fetchImpl(registrationEndpoint, {
654
680
  method: "POST",
681
+ redirect: "error",
682
+ signal: AbortSignal.timeout(3e4),
655
683
  headers: { "content-type": "application/json" },
656
684
  body: JSON.stringify({
657
685
  client_name: "NimblyBase CLI",
@@ -730,6 +758,8 @@ function openBrowser(url) {
730
758
  async function exchange(input, fetchImpl = fetch) {
731
759
  const res = await fetchImpl(input.tokenEndpoint, {
732
760
  method: "POST",
761
+ redirect: "error",
762
+ signal: AbortSignal.timeout(3e4),
733
763
  headers: { "content-type": "application/x-www-form-urlencoded" },
734
764
  body: new URLSearchParams({
735
765
  grant_type: "authorization_code",
@@ -741,19 +771,76 @@ async function exchange(input, fetchImpl = fetch) {
741
771
  });
742
772
  const body = await res.json();
743
773
  if (!res.ok) throw new Error(`the sign-in could not be completed (${body.error ?? res.status})`);
774
+ return grantResponse(body, input.clientId);
775
+ }
776
+ function grantResponse(body, clientId) {
777
+ if (typeof body.access_token !== "string" || !/^nb_oat_[A-Za-z0-9_-]+$/.test(body.access_token) || typeof body.refresh_token !== "string" || !/^nb_ort_[A-Za-z0-9_-]+$/.test(body.refresh_token) || typeof body.expires_in !== "number" || !Number.isInteger(body.expires_in) || body.expires_in < 1 || body.expires_in > 900 || body.token_type !== void 0 && body.token_type !== "Bearer" || body.scope !== void 0 && typeof body.scope !== "string") {
778
+ throw new Error("the authorization server returned an invalid credential response");
779
+ }
744
780
  return {
745
- accessToken: String(body.access_token),
746
- refreshToken: String(body.refresh_token),
747
- // Ours, not theirs: a clock ahead of the server's shortens the
748
- // window, which is the direction that fails safe.
749
- expiresAt: Date.now() + Number(body.expires_in ?? 900) * 1e3,
750
- clientId: input.clientId,
781
+ accessToken: body.access_token,
782
+ refreshToken: body.refresh_token,
783
+ expiresAt: Date.now() + body.expires_in * 1e3,
784
+ clientId,
751
785
  scopes: String(body.scope ?? "").split(" ").filter(Boolean)
752
786
  };
753
787
  }
788
+ async function deviceLogin(input, fetchImpl = fetch) {
789
+ const doc = await discover(input.issuer, fetchImpl);
790
+ if (!doc.device_authorization_endpoint || !doc.registration_endpoint) {
791
+ throw new Error("this server does not offer device sign-in and client registration");
792
+ }
793
+ const clientId = await registerClient(doc.registration_endpoint, fetchImpl);
794
+ const start = await fetchImpl(doc.device_authorization_endpoint, {
795
+ method: "POST",
796
+ redirect: "error",
797
+ signal: AbortSignal.timeout(3e4),
798
+ headers: { "content-type": "application/x-www-form-urlencoded" },
799
+ body: new URLSearchParams({ client_id: clientId }).toString()
800
+ });
801
+ if (!start.ok) throw new Error("device sign-in could not be started");
802
+ const device = await start.json();
803
+ if (typeof device.device_code !== "string" || !/^nb_oat_[A-Za-z0-9_-]+$/.test(device.device_code) || typeof device.user_code !== "string" || !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(device.user_code) || typeof device.verification_uri !== "string" || typeof device.expires_in !== "number" || !Number.isInteger(device.expires_in) || device.expires_in < 1 || device.expires_in > 900 || device.interval !== void 0 && (!Number.isInteger(device.interval) || device.interval < 1 || device.interval > 60)) {
804
+ throw new Error("the server returned an invalid device authorization response");
805
+ }
806
+ const verification = new URL(device.verification_uri);
807
+ if (verification.origin !== new URL(doc.authorization_endpoint).origin || verification.username || verification.password || verification.search || verification.hash) {
808
+ throw new Error("the device verification page is outside the authorization origin");
809
+ }
810
+ const now = input.now ?? (() => performance.now());
811
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
812
+ const deadline = now() + device.expires_in * 1e3;
813
+ let interval = Math.max(5, device.interval ?? 5) * 1e3;
814
+ input.announce({ verificationUri: verification.toString(), userCode: device.user_code, expiresInSeconds: device.expires_in });
815
+ while (now() < deadline) {
816
+ await sleep(Math.min(interval, Math.max(0, deadline - now())));
817
+ const remaining = deadline - now();
818
+ if (remaining <= 0) break;
819
+ const poll = await fetchImpl(doc.token_endpoint, {
820
+ method: "POST",
821
+ redirect: "error",
822
+ signal: AbortSignal.timeout(Math.min(3e4, remaining)),
823
+ headers: { "content-type": "application/x-www-form-urlencoded" },
824
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", client_id: clientId, device_code: device.device_code }).toString()
825
+ });
826
+ const body = await poll.json();
827
+ if (poll.ok) return grantResponse(body, clientId);
828
+ if (body.error === "authorization_pending") continue;
829
+ if (body.error === "slow_down") {
830
+ interval += 5e3;
831
+ continue;
832
+ }
833
+ if (body.error === "access_denied") throw new Error("you declined device sign-in; your previous login was kept");
834
+ if (body.error === "expired_token") break;
835
+ throw new Error("device sign-in failed; your previous login was kept");
836
+ }
837
+ throw new Error("device sign-in expired; your previous login was kept");
838
+ }
754
839
  async function refreshGrant(input, fetchImpl = fetch) {
755
840
  const res = await fetchImpl(input.tokenEndpoint, {
756
841
  method: "POST",
842
+ redirect: "error",
843
+ signal: AbortSignal.timeout(3e4),
757
844
  headers: { "content-type": "application/x-www-form-urlencoded" },
758
845
  body: new URLSearchParams({
759
846
  grant_type: "refresh_token",
@@ -763,13 +850,7 @@ async function refreshGrant(input, fetchImpl = fetch) {
763
850
  });
764
851
  const body = await res.json();
765
852
  if (!res.ok) throw new Error(String(body.error ?? res.status));
766
- return {
767
- accessToken: String(body.access_token),
768
- refreshToken: String(body.refresh_token),
769
- expiresAt: Date.now() + Number(body.expires_in ?? 900) * 1e3,
770
- clientId: input.clientId,
771
- scopes: String(body.scope ?? "").split(" ").filter(Boolean)
772
- };
853
+ return grantResponse(body, input.clientId);
773
854
  }
774
855
  async function browserLogin(input) {
775
856
  const doc = await discover(input.issuer);
@@ -823,7 +904,8 @@ import { execFile } from "node:child_process";
823
904
  import { promisify } from "node:util";
824
905
  import { homedir } from "node:os";
825
906
  import { join, dirname } from "node:path";
826
- import { mkdir, readFile, writeFile, chmod, rm, stat } from "node:fs/promises";
907
+ import { mkdir, readFile, writeFile, chmod, rm, stat, rename } from "node:fs/promises";
908
+ import { randomUUID } from "node:crypto";
827
909
  var run = promisify(execFile);
828
910
  var SERVICE = "nimblybase-cli";
829
911
  var ACCOUNT = "access-token";
@@ -874,15 +956,16 @@ async function keychainDelete() {
874
956
  return false;
875
957
  }
876
958
  }
877
- function encodeSecret(value) {
878
- return typeof value === "string" ? value : JSON.stringify(value);
959
+ function encodeSecret(value, apiUrl2) {
960
+ return typeof value === "string" ? apiUrl2 ? JSON.stringify({ token: value, apiUrl: apiUrl2 }) : value : JSON.stringify(value);
879
961
  }
880
962
  function decodeSecret(raw) {
881
963
  const trimmed = raw.trim();
882
964
  if (!trimmed.startsWith("{")) return { token: trimmed };
883
965
  try {
884
966
  const parsed = JSON.parse(trimmed);
885
- return parsed.refreshToken ? { grant: parsed } : {};
967
+ if (parsed.refreshToken) return { grant: parsed };
968
+ return typeof parsed.token === "string" && typeof parsed.apiUrl === "string" ? { token: parsed.token, apiUrl: parsed.apiUrl } : {};
886
969
  } catch {
887
970
  return {};
888
971
  }
@@ -894,18 +977,25 @@ async function fileRead() {
894
977
  return null;
895
978
  }
896
979
  }
897
- async function fileWrite(value) {
980
+ async function fileWrite(value, apiUrl2) {
898
981
  const path = credentialsPath();
899
982
  await mkdir(dirname(path), { recursive: true, mode: 448 });
900
- const body = typeof value === "string" ? { token: value } : { grant: value };
901
- await writeFile(path, `${JSON.stringify(body, null, 2)}
902
- `, { mode: 384 });
903
- await chmod(path, 384);
983
+ await chmod(dirname(path), 448);
984
+ const body = typeof value === "string" ? { token: value, ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : { grant: value };
985
+ const temporary = `${path}.${randomUUID()}.tmp`;
986
+ try {
987
+ await writeFile(temporary, `${JSON.stringify(body, null, 2)}
988
+ `, { mode: 384, flag: "wx" });
989
+ await chmod(temporary, 384);
990
+ await rename(temporary, path);
991
+ } finally {
992
+ await rm(temporary, { force: true });
993
+ }
904
994
  }
905
995
  var credentials = {
906
- async save(token) {
907
- if (await keychainSet(token)) return "keychain";
908
- await fileWrite(token);
996
+ async save(token, apiUrl2) {
997
+ if (await keychainSet(encodeSecret(token, apiUrl2))) return "keychain";
998
+ await fileWrite(token, apiUrl2);
909
999
  return "file";
910
1000
  },
911
1001
  async saveGrant(grant) {
@@ -918,19 +1008,21 @@ var credentials = {
918
1008
  if (fromEnv) return { token: fromEnv, where: "environment" };
919
1009
  const fromKeychain = await keychainGet();
920
1010
  if (fromKeychain) {
921
- const { token, grant } = decodeSecret(fromKeychain);
1011
+ const { token, grant, apiUrl: apiUrl3 } = decodeSecret(fromKeychain);
922
1012
  const value2 = token ?? grant?.accessToken;
923
- if (value2) return { token: value2, where: "keychain" };
1013
+ if (value2) return { token: value2, where: "keychain", ...apiUrl3 || grant?.issuer ? { apiUrl: apiUrl3 ?? grant.issuer } : {} };
924
1014
  }
925
1015
  const fromFile = await fileRead();
926
1016
  const value = fromFile?.token ?? fromFile?.grant?.accessToken;
927
- return value ? { token: value, where: "file" } : null;
1017
+ const apiUrl2 = fromFile?.apiUrl ?? fromFile?.grant?.issuer;
1018
+ return value ? { token: value, where: "file", ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : null;
928
1019
  },
929
1020
  async loadGrant() {
1021
+ if (process.env.NIMBLYBASE_TOKEN?.trim()) return null;
930
1022
  const fromKeychain = await keychainGet();
931
1023
  if (fromKeychain) {
932
1024
  const { grant } = decodeSecret(fromKeychain);
933
- if (grant) return { grant, where: "keychain" };
1025
+ return grant ? { grant, where: "keychain" } : null;
934
1026
  }
935
1027
  const fromFile = await fileRead();
936
1028
  return fromFile?.grant ? { grant: fromFile.grant, where: "file" } : null;
@@ -1006,8 +1098,8 @@ async function confirm(question) {
1006
1098
  }
1007
1099
 
1008
1100
  // src/link.ts
1009
- import { readFile as readFile2, rename, rm as rm2, writeFile as writeFile2, link as hardLink } from "node:fs/promises";
1010
- import { randomUUID } from "node:crypto";
1101
+ import { readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2, link as hardLink } from "node:fs/promises";
1102
+ import { randomUUID as randomUUID2 } from "node:crypto";
1011
1103
  import { dirname as dirname2, join as join2, parse as parse2, resolve } from "node:path";
1012
1104
  var LINK_FILE = "nimblybase.json";
1013
1105
  var BOOTSTRAP_FILE = ".nimblybase.bootstrap.json";
@@ -1089,11 +1181,11 @@ async function writeLink(dir, link2, options = {}) {
1089
1181
  slug: link2.slug
1090
1182
  };
1091
1183
  if (link2.apiUrl !== void 0) next.apiUrl = link2.apiUrl;
1092
- const temporary = join2(dirname2(path), `.${LINK_FILE}.${process.pid}.${randomUUID()}.tmp`);
1184
+ const temporary = join2(dirname2(path), `.${LINK_FILE}.${process.pid}.${randomUUID2()}.tmp`);
1093
1185
  try {
1094
1186
  await writeFile2(temporary, `${JSON.stringify(next, null, 2)}
1095
1187
  `, { flag: "wx" });
1096
- await rename(temporary, path);
1188
+ await rename2(temporary, path);
1097
1189
  } catch (err) {
1098
1190
  await rm2(temporary, { force: true });
1099
1191
  throw err;
@@ -1122,8 +1214,8 @@ async function readPendingBootstrap(dir) {
1122
1214
  }
1123
1215
  async function pendingBootstrap(dir, input) {
1124
1216
  const path = join2(dir, BOOTSTRAP_FILE);
1125
- const temporary = join2(dir, `.nimblybase.bootstrap.${process.pid}.${randomUUID()}.tmp`);
1126
- const pending = { schemaVersion: 2, requestId: randomUUID(), ...input };
1217
+ const temporary = join2(dir, `.nimblybase.bootstrap.${process.pid}.${randomUUID2()}.tmp`);
1218
+ const pending = { schemaVersion: 2, requestId: randomUUID2(), ...input };
1127
1219
  try {
1128
1220
  await writeFile2(temporary, `${JSON.stringify(pending, null, 2)}
1129
1221
  `, { flag: "wx", mode: 384 });
@@ -1147,8 +1239,24 @@ async function clearPendingBootstrap(dir) {
1147
1239
  // src/context.ts
1148
1240
  async function context(apiOverride) {
1149
1241
  const link2 = await findLink();
1150
- const base = apiUrl(apiOverride ?? process.env.NIMBLYBASE_API_URL ?? link2?.apiUrl);
1242
+ const storedBeforeRefresh = await credentials.load();
1243
+ const base = apiUrl(apiOverride ?? process.env.NIMBLYBASE_API_URL ?? link2?.apiUrl ?? (storedBeforeRefresh?.where !== "environment" ? storedBeforeRefresh?.apiUrl : void 0));
1151
1244
  const held = await credentials.loadGrant();
1245
+ if (storedBeforeRefresh && storedBeforeRefresh.where !== "environment") {
1246
+ const bound = apiUrl(storedBeforeRefresh.apiUrl ?? "https://api-uat.nimblybase.com");
1247
+ if (base !== bound) throw new CliError(
1248
+ "This credential belongs to a different API.",
1249
+ EXIT.unauthenticated,
1250
+ "Run `nimbly login --api <destination>` to authorize that API. Your stored login was kept."
1251
+ );
1252
+ }
1253
+ if (storedBeforeRefresh?.where === "environment" && link2?.apiUrl && !apiOverride && !process.env.NIMBLYBASE_API_URL && base !== apiUrl(DEFAULT_API_URL)) {
1254
+ throw new CliError(
1255
+ "An environment token cannot be sent to an API selected only by a repository file.",
1256
+ EXIT.usage,
1257
+ "Set NIMBLYBASE_API_URL or pass --api explicitly for this automation identity."
1258
+ );
1259
+ }
1152
1260
  if (held && held.grant.expiresAt - Date.now() < 6e4) {
1153
1261
  try {
1154
1262
  const fresh = await refreshGrant({
@@ -1215,8 +1323,17 @@ var WHERE_SAID = {
1215
1323
  async function login(flags) {
1216
1324
  const base = apiUrl(flags.api);
1217
1325
  const given = flags.token ?? await readPipedInput();
1326
+ if (flags.device && (given || flags.tokenOnly)) throw new CliError("Choose device sign-in or a pasted token, not both.", EXIT.usage);
1218
1327
  if (!given && !flags.tokenOnly) {
1219
- const grant = await browserLogin({
1328
+ const grant = flags.device ? await deviceLogin({
1329
+ issuer: base,
1330
+ announce: ({ verificationUri, userCode, expiresInSeconds }) => {
1331
+ note(`On another device, open:
1332
+ ${verificationUri}
1333
+ Enter code: ${userCode}
1334
+ It expires in ${expiresInSeconds} seconds. Approve only the CLI request you started.`);
1335
+ }
1336
+ }) : await browserLogin({
1220
1337
  issuer: base,
1221
1338
  announce: (url) => {
1222
1339
  out("Opening your browser to sign in.");
@@ -1225,8 +1342,8 @@ async function login(flags) {
1225
1342
  },
1226
1343
  open: openBrowser
1227
1344
  });
1228
- const where2 = await credentials.saveGrant({ ...grant, issuer: base });
1229
1345
  const me2 = await createClient(base, grant.accessToken).me();
1346
+ const where2 = await credentials.saveGrant({ ...grant, issuer: base });
1230
1347
  if (isJsonMode()) {
1231
1348
  json({ ok: true, storedIn: where2, organization: me2.organization, api: base, scopes: grant.scopes });
1232
1349
  return;
@@ -1246,7 +1363,7 @@ async function login(flags) {
1246
1363
  );
1247
1364
  }
1248
1365
  const me = await createClient(base, token).me();
1249
- const where = await credentials.save(token);
1366
+ const where = await credentials.save(token, base);
1250
1367
  if (isJsonMode()) {
1251
1368
  json({ ok: true, storedIn: where, organization: me.organization, api: base });
1252
1369
  return;
@@ -7849,7 +7966,7 @@ async function mcp(flags, which) {
7849
7966
  }
7850
7967
 
7851
7968
  // src/commands/db.ts
7852
- import { randomUUID as randomUUID2 } from "node:crypto";
7969
+ import { randomUUID as randomUUID3 } from "node:crypto";
7853
7970
  import { readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
7854
7971
  import { dirname as dirname3, isAbsolute, join as join4, resolve as resolve5 } from "node:path";
7855
7972
  var DEFAULT_SCHEMA_FILE = "schema.json";
@@ -7886,7 +8003,7 @@ var lineageOf = (parsed) => {
7886
8003
  const raw = parsed?.lineage;
7887
8004
  return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
7888
8005
  };
7889
- var newLineage = () => `cli-${randomUUID2()}`;
8006
+ var newLineage = () => `cli-${randomUUID3()}`;
7890
8007
  async function ensureLineage(path, parsed) {
7891
8008
  const existing = lineageOf(parsed);
7892
8009
  if (existing) return existing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimblybase",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "The NimblyBase command line - projects, keys and backends from a terminal.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {