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 +8 -1
- package/dist/index.js +67 -4
- package/package.json +1 -1
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,
|
|
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
|
|
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.
|
|
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
|
|
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.");
|