nimblybase 0.1.5 → 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.
package/README.md CHANGED
@@ -30,7 +30,14 @@ 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
 
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,7 +373,7 @@ 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.5";
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;
@@ -660,7 +663,7 @@ async function discover(issuer, fetchImpl = fetch) {
660
663
  if (!doc.authorization_endpoint || !doc.token_endpoint) {
661
664
  throw new Error("the authorization server metadata is missing an endpoint this flow needs");
662
665
  }
663
- for (const endpoint of [doc.token_endpoint, doc.registration_endpoint].filter(Boolean)) {
666
+ for (const endpoint of [doc.token_endpoint, doc.registration_endpoint, doc.device_authorization_endpoint].filter(Boolean)) {
664
667
  const target = new URL(endpoint);
665
668
  if (target.origin !== origin || target.username || target.password || target.hash) {
666
669
  throw new Error("the authorization server advertises a credential endpoint outside its API origin");
@@ -782,6 +785,57 @@ function grantResponse(body, clientId) {
782
785
  scopes: String(body.scope ?? "").split(" ").filter(Boolean)
783
786
  };
784
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
+ }
785
839
  async function refreshGrant(input, fetchImpl = fetch) {
786
840
  const res = await fetchImpl(input.tokenEndpoint, {
787
841
  method: "POST",
@@ -1269,8 +1323,17 @@ var WHERE_SAID = {
1269
1323
  async function login(flags) {
1270
1324
  const base = apiUrl(flags.api);
1271
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);
1272
1327
  if (!given && !flags.tokenOnly) {
1273
- 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({
1274
1337
  issuer: base,
1275
1338
  announce: (url) => {
1276
1339
  out("Opening your browser to sign in.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimblybase",
3
- "version": "0.1.5",
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": {