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.
- package/README.md +17 -1
- package/dist/index.js +163 -46
- 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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
642
|
-
const
|
|
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:
|
|
746
|
-
refreshToken:
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}.${
|
|
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
|
|
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}.${
|
|
1126
|
-
const pending = { schemaVersion: 2, requestId:
|
|
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
|
|
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
|
|
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
|
|
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-${
|
|
8006
|
+
var newLineage = () => `cli-${randomUUID3()}`;
|
|
7890
8007
|
async function ensureLineage(path, parsed) {
|
|
7891
8008
|
const existing = lineageOf(parsed);
|
|
7892
8009
|
if (existing) return existing;
|