nimblybase 0.1.4 → 0.1.5
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 +9 -0
- package/dist/index.js +98 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,15 @@ value prompts without echoing it.
|
|
|
37
37
|
In CI, set `NIMBLYBASE_TOKEN` instead. It takes precedence over anything
|
|
38
38
|
stored and is never written to disk.
|
|
39
39
|
|
|
40
|
+
Stored browser grants and newly saved access tokens stay bound to the API used
|
|
41
|
+
at login. A repository link, `--api`, or environment setting cannot retarget that
|
|
42
|
+
credential. To switch servers, explicitly run `nimbly login --api <destination>`;
|
|
43
|
+
the previous login is kept if verification fails. Older bare tokens remain usable
|
|
44
|
+
on the original UAT default until you log in again to bind another destination.
|
|
45
|
+
An injected CI token requires an explicit `NIMBLYBASE_API_URL`/`--api` when a
|
|
46
|
+
repository selects a different API. It never refreshes a separate personal login.
|
|
47
|
+
Use HTTPS; local development may use literal `http://127.0.0.1:<port>` or `[::1]`.
|
|
48
|
+
|
|
40
49
|
## Requirements before creating
|
|
41
50
|
|
|
42
51
|
For a new empty application, choose `--recipe static-d1` explicitly when a static
|
package/dist/index.js
CHANGED
|
@@ -370,19 +370,31 @@ function when(iso) {
|
|
|
370
370
|
|
|
371
371
|
// src/api.ts
|
|
372
372
|
var DEFAULT_API_URL = "https://api-uat.nimblybase.com";
|
|
373
|
-
var VERSION = "0.1.
|
|
373
|
+
var VERSION = "0.1.5";
|
|
374
374
|
function dashboardUrl(api) {
|
|
375
375
|
const match = /^https:\/\/api(-[a-z0-9]+)?\.(.+)$/.exec(api);
|
|
376
376
|
return match ? `https://app${match[1] ?? ""}.${match[2]}` : null;
|
|
377
377
|
}
|
|
378
378
|
function apiUrl(override) {
|
|
379
|
-
|
|
379
|
+
const raw = override ?? process.env.NIMBLYBASE_API_URL ?? DEFAULT_API_URL;
|
|
380
|
+
let parsed;
|
|
381
|
+
try {
|
|
382
|
+
parsed = new URL(raw);
|
|
383
|
+
} catch {
|
|
384
|
+
throw new CliError("Invalid API address.", EXIT.usage);
|
|
385
|
+
}
|
|
386
|
+
const loopback = ["127.0.0.1", "[::1]"].includes(parsed.hostname);
|
|
387
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash || !/^\/*$/.test(parsed.pathname) || parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
|
|
388
|
+
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);
|
|
389
|
+
}
|
|
390
|
+
return parsed.origin;
|
|
380
391
|
}
|
|
381
392
|
async function request(base, token, path, init2 = {}) {
|
|
382
393
|
let res;
|
|
383
394
|
try {
|
|
384
395
|
res = await fetch(`${base}${path}`, {
|
|
385
396
|
...init2,
|
|
397
|
+
redirect: "error",
|
|
386
398
|
headers: {
|
|
387
399
|
authorization: `Bearer ${token}`,
|
|
388
400
|
...init2.body ? { "content-type": "application/json" } : {},
|
|
@@ -638,8 +650,9 @@ function pkcePair() {
|
|
|
638
650
|
return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) };
|
|
639
651
|
}
|
|
640
652
|
async function discover(issuer, fetchImpl = fetch) {
|
|
641
|
-
const
|
|
642
|
-
const
|
|
653
|
+
const origin = apiUrl(issuer);
|
|
654
|
+
const url = new URL("/.well-known/oauth-authorization-server", origin).toString();
|
|
655
|
+
const res = await fetchImpl(url, { redirect: "error", signal: AbortSignal.timeout(3e4) });
|
|
643
656
|
if (!res.ok) {
|
|
644
657
|
throw new Error(`${issuer} does not advertise an authorization server (${res.status} from ${url})`);
|
|
645
658
|
}
|
|
@@ -647,11 +660,23 @@ async function discover(issuer, fetchImpl = fetch) {
|
|
|
647
660
|
if (!doc.authorization_endpoint || !doc.token_endpoint) {
|
|
648
661
|
throw new Error("the authorization server metadata is missing an endpoint this flow needs");
|
|
649
662
|
}
|
|
663
|
+
for (const endpoint of [doc.token_endpoint, doc.registration_endpoint].filter(Boolean)) {
|
|
664
|
+
const target = new URL(endpoint);
|
|
665
|
+
if (target.origin !== origin || target.username || target.password || target.hash) {
|
|
666
|
+
throw new Error("the authorization server advertises a credential endpoint outside its API origin");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const authorize = new URL(doc.authorization_endpoint);
|
|
670
|
+
if (authorize.username || authorize.password || authorize.hash || authorize.protocol !== "https:" && !(authorize.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(authorize.hostname))) {
|
|
671
|
+
throw new Error("the authorization endpoint must use HTTPS, or literal loopback HTTP");
|
|
672
|
+
}
|
|
650
673
|
return doc;
|
|
651
674
|
}
|
|
652
675
|
async function registerClient(registrationEndpoint, fetchImpl = fetch) {
|
|
653
676
|
const res = await fetchImpl(registrationEndpoint, {
|
|
654
677
|
method: "POST",
|
|
678
|
+
redirect: "error",
|
|
679
|
+
signal: AbortSignal.timeout(3e4),
|
|
655
680
|
headers: { "content-type": "application/json" },
|
|
656
681
|
body: JSON.stringify({
|
|
657
682
|
client_name: "NimblyBase CLI",
|
|
@@ -730,6 +755,8 @@ function openBrowser(url) {
|
|
|
730
755
|
async function exchange(input, fetchImpl = fetch) {
|
|
731
756
|
const res = await fetchImpl(input.tokenEndpoint, {
|
|
732
757
|
method: "POST",
|
|
758
|
+
redirect: "error",
|
|
759
|
+
signal: AbortSignal.timeout(3e4),
|
|
733
760
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
734
761
|
body: new URLSearchParams({
|
|
735
762
|
grant_type: "authorization_code",
|
|
@@ -741,19 +768,25 @@ async function exchange(input, fetchImpl = fetch) {
|
|
|
741
768
|
});
|
|
742
769
|
const body = await res.json();
|
|
743
770
|
if (!res.ok) throw new Error(`the sign-in could not be completed (${body.error ?? res.status})`);
|
|
771
|
+
return grantResponse(body, input.clientId);
|
|
772
|
+
}
|
|
773
|
+
function grantResponse(body, clientId) {
|
|
774
|
+
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") {
|
|
775
|
+
throw new Error("the authorization server returned an invalid credential response");
|
|
776
|
+
}
|
|
744
777
|
return {
|
|
745
|
-
accessToken:
|
|
746
|
-
refreshToken:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
expiresAt: Date.now() + Number(body.expires_in ?? 900) * 1e3,
|
|
750
|
-
clientId: input.clientId,
|
|
778
|
+
accessToken: body.access_token,
|
|
779
|
+
refreshToken: body.refresh_token,
|
|
780
|
+
expiresAt: Date.now() + body.expires_in * 1e3,
|
|
781
|
+
clientId,
|
|
751
782
|
scopes: String(body.scope ?? "").split(" ").filter(Boolean)
|
|
752
783
|
};
|
|
753
784
|
}
|
|
754
785
|
async function refreshGrant(input, fetchImpl = fetch) {
|
|
755
786
|
const res = await fetchImpl(input.tokenEndpoint, {
|
|
756
787
|
method: "POST",
|
|
788
|
+
redirect: "error",
|
|
789
|
+
signal: AbortSignal.timeout(3e4),
|
|
757
790
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
758
791
|
body: new URLSearchParams({
|
|
759
792
|
grant_type: "refresh_token",
|
|
@@ -763,13 +796,7 @@ async function refreshGrant(input, fetchImpl = fetch) {
|
|
|
763
796
|
});
|
|
764
797
|
const body = await res.json();
|
|
765
798
|
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
|
-
};
|
|
799
|
+
return grantResponse(body, input.clientId);
|
|
773
800
|
}
|
|
774
801
|
async function browserLogin(input) {
|
|
775
802
|
const doc = await discover(input.issuer);
|
|
@@ -823,7 +850,8 @@ import { execFile } from "node:child_process";
|
|
|
823
850
|
import { promisify } from "node:util";
|
|
824
851
|
import { homedir } from "node:os";
|
|
825
852
|
import { join, dirname } from "node:path";
|
|
826
|
-
import { mkdir, readFile, writeFile, chmod, rm, stat } from "node:fs/promises";
|
|
853
|
+
import { mkdir, readFile, writeFile, chmod, rm, stat, rename } from "node:fs/promises";
|
|
854
|
+
import { randomUUID } from "node:crypto";
|
|
827
855
|
var run = promisify(execFile);
|
|
828
856
|
var SERVICE = "nimblybase-cli";
|
|
829
857
|
var ACCOUNT = "access-token";
|
|
@@ -874,15 +902,16 @@ async function keychainDelete() {
|
|
|
874
902
|
return false;
|
|
875
903
|
}
|
|
876
904
|
}
|
|
877
|
-
function encodeSecret(value) {
|
|
878
|
-
return typeof value === "string" ? value : JSON.stringify(value);
|
|
905
|
+
function encodeSecret(value, apiUrl2) {
|
|
906
|
+
return typeof value === "string" ? apiUrl2 ? JSON.stringify({ token: value, apiUrl: apiUrl2 }) : value : JSON.stringify(value);
|
|
879
907
|
}
|
|
880
908
|
function decodeSecret(raw) {
|
|
881
909
|
const trimmed = raw.trim();
|
|
882
910
|
if (!trimmed.startsWith("{")) return { token: trimmed };
|
|
883
911
|
try {
|
|
884
912
|
const parsed = JSON.parse(trimmed);
|
|
885
|
-
|
|
913
|
+
if (parsed.refreshToken) return { grant: parsed };
|
|
914
|
+
return typeof parsed.token === "string" && typeof parsed.apiUrl === "string" ? { token: parsed.token, apiUrl: parsed.apiUrl } : {};
|
|
886
915
|
} catch {
|
|
887
916
|
return {};
|
|
888
917
|
}
|
|
@@ -894,18 +923,25 @@ async function fileRead() {
|
|
|
894
923
|
return null;
|
|
895
924
|
}
|
|
896
925
|
}
|
|
897
|
-
async function fileWrite(value) {
|
|
926
|
+
async function fileWrite(value, apiUrl2) {
|
|
898
927
|
const path = credentialsPath();
|
|
899
928
|
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
929
|
+
await chmod(dirname(path), 448);
|
|
930
|
+
const body = typeof value === "string" ? { token: value, ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : { grant: value };
|
|
931
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
932
|
+
try {
|
|
933
|
+
await writeFile(temporary, `${JSON.stringify(body, null, 2)}
|
|
934
|
+
`, { mode: 384, flag: "wx" });
|
|
935
|
+
await chmod(temporary, 384);
|
|
936
|
+
await rename(temporary, path);
|
|
937
|
+
} finally {
|
|
938
|
+
await rm(temporary, { force: true });
|
|
939
|
+
}
|
|
904
940
|
}
|
|
905
941
|
var credentials = {
|
|
906
|
-
async save(token) {
|
|
907
|
-
if (await keychainSet(token)) return "keychain";
|
|
908
|
-
await fileWrite(token);
|
|
942
|
+
async save(token, apiUrl2) {
|
|
943
|
+
if (await keychainSet(encodeSecret(token, apiUrl2))) return "keychain";
|
|
944
|
+
await fileWrite(token, apiUrl2);
|
|
909
945
|
return "file";
|
|
910
946
|
},
|
|
911
947
|
async saveGrant(grant) {
|
|
@@ -918,19 +954,21 @@ var credentials = {
|
|
|
918
954
|
if (fromEnv) return { token: fromEnv, where: "environment" };
|
|
919
955
|
const fromKeychain = await keychainGet();
|
|
920
956
|
if (fromKeychain) {
|
|
921
|
-
const { token, grant } = decodeSecret(fromKeychain);
|
|
957
|
+
const { token, grant, apiUrl: apiUrl3 } = decodeSecret(fromKeychain);
|
|
922
958
|
const value2 = token ?? grant?.accessToken;
|
|
923
|
-
if (value2) return { token: value2, where: "keychain" };
|
|
959
|
+
if (value2) return { token: value2, where: "keychain", ...apiUrl3 || grant?.issuer ? { apiUrl: apiUrl3 ?? grant.issuer } : {} };
|
|
924
960
|
}
|
|
925
961
|
const fromFile = await fileRead();
|
|
926
962
|
const value = fromFile?.token ?? fromFile?.grant?.accessToken;
|
|
927
|
-
|
|
963
|
+
const apiUrl2 = fromFile?.apiUrl ?? fromFile?.grant?.issuer;
|
|
964
|
+
return value ? { token: value, where: "file", ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : null;
|
|
928
965
|
},
|
|
929
966
|
async loadGrant() {
|
|
967
|
+
if (process.env.NIMBLYBASE_TOKEN?.trim()) return null;
|
|
930
968
|
const fromKeychain = await keychainGet();
|
|
931
969
|
if (fromKeychain) {
|
|
932
970
|
const { grant } = decodeSecret(fromKeychain);
|
|
933
|
-
|
|
971
|
+
return grant ? { grant, where: "keychain" } : null;
|
|
934
972
|
}
|
|
935
973
|
const fromFile = await fileRead();
|
|
936
974
|
return fromFile?.grant ? { grant: fromFile.grant, where: "file" } : null;
|
|
@@ -1006,8 +1044,8 @@ async function confirm(question) {
|
|
|
1006
1044
|
}
|
|
1007
1045
|
|
|
1008
1046
|
// 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";
|
|
1047
|
+
import { readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2, link as hardLink } from "node:fs/promises";
|
|
1048
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1011
1049
|
import { dirname as dirname2, join as join2, parse as parse2, resolve } from "node:path";
|
|
1012
1050
|
var LINK_FILE = "nimblybase.json";
|
|
1013
1051
|
var BOOTSTRAP_FILE = ".nimblybase.bootstrap.json";
|
|
@@ -1089,11 +1127,11 @@ async function writeLink(dir, link2, options = {}) {
|
|
|
1089
1127
|
slug: link2.slug
|
|
1090
1128
|
};
|
|
1091
1129
|
if (link2.apiUrl !== void 0) next.apiUrl = link2.apiUrl;
|
|
1092
|
-
const temporary = join2(dirname2(path), `.${LINK_FILE}.${process.pid}.${
|
|
1130
|
+
const temporary = join2(dirname2(path), `.${LINK_FILE}.${process.pid}.${randomUUID2()}.tmp`);
|
|
1093
1131
|
try {
|
|
1094
1132
|
await writeFile2(temporary, `${JSON.stringify(next, null, 2)}
|
|
1095
1133
|
`, { flag: "wx" });
|
|
1096
|
-
await
|
|
1134
|
+
await rename2(temporary, path);
|
|
1097
1135
|
} catch (err) {
|
|
1098
1136
|
await rm2(temporary, { force: true });
|
|
1099
1137
|
throw err;
|
|
@@ -1122,8 +1160,8 @@ async function readPendingBootstrap(dir) {
|
|
|
1122
1160
|
}
|
|
1123
1161
|
async function pendingBootstrap(dir, input) {
|
|
1124
1162
|
const path = join2(dir, BOOTSTRAP_FILE);
|
|
1125
|
-
const temporary = join2(dir, `.nimblybase.bootstrap.${process.pid}.${
|
|
1126
|
-
const pending = { schemaVersion: 2, requestId:
|
|
1163
|
+
const temporary = join2(dir, `.nimblybase.bootstrap.${process.pid}.${randomUUID2()}.tmp`);
|
|
1164
|
+
const pending = { schemaVersion: 2, requestId: randomUUID2(), ...input };
|
|
1127
1165
|
try {
|
|
1128
1166
|
await writeFile2(temporary, `${JSON.stringify(pending, null, 2)}
|
|
1129
1167
|
`, { flag: "wx", mode: 384 });
|
|
@@ -1147,8 +1185,24 @@ async function clearPendingBootstrap(dir) {
|
|
|
1147
1185
|
// src/context.ts
|
|
1148
1186
|
async function context(apiOverride) {
|
|
1149
1187
|
const link2 = await findLink();
|
|
1150
|
-
const
|
|
1188
|
+
const storedBeforeRefresh = await credentials.load();
|
|
1189
|
+
const base = apiUrl(apiOverride ?? process.env.NIMBLYBASE_API_URL ?? link2?.apiUrl ?? (storedBeforeRefresh?.where !== "environment" ? storedBeforeRefresh?.apiUrl : void 0));
|
|
1151
1190
|
const held = await credentials.loadGrant();
|
|
1191
|
+
if (storedBeforeRefresh && storedBeforeRefresh.where !== "environment") {
|
|
1192
|
+
const bound = apiUrl(storedBeforeRefresh.apiUrl ?? "https://api-uat.nimblybase.com");
|
|
1193
|
+
if (base !== bound) throw new CliError(
|
|
1194
|
+
"This credential belongs to a different API.",
|
|
1195
|
+
EXIT.unauthenticated,
|
|
1196
|
+
"Run `nimbly login --api <destination>` to authorize that API. Your stored login was kept."
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
if (storedBeforeRefresh?.where === "environment" && link2?.apiUrl && !apiOverride && !process.env.NIMBLYBASE_API_URL && base !== apiUrl(DEFAULT_API_URL)) {
|
|
1200
|
+
throw new CliError(
|
|
1201
|
+
"An environment token cannot be sent to an API selected only by a repository file.",
|
|
1202
|
+
EXIT.usage,
|
|
1203
|
+
"Set NIMBLYBASE_API_URL or pass --api explicitly for this automation identity."
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1152
1206
|
if (held && held.grant.expiresAt - Date.now() < 6e4) {
|
|
1153
1207
|
try {
|
|
1154
1208
|
const fresh = await refreshGrant({
|
|
@@ -1225,8 +1279,8 @@ async function login(flags) {
|
|
|
1225
1279
|
},
|
|
1226
1280
|
open: openBrowser
|
|
1227
1281
|
});
|
|
1228
|
-
const where2 = await credentials.saveGrant({ ...grant, issuer: base });
|
|
1229
1282
|
const me2 = await createClient(base, grant.accessToken).me();
|
|
1283
|
+
const where2 = await credentials.saveGrant({ ...grant, issuer: base });
|
|
1230
1284
|
if (isJsonMode()) {
|
|
1231
1285
|
json({ ok: true, storedIn: where2, organization: me2.organization, api: base, scopes: grant.scopes });
|
|
1232
1286
|
return;
|
|
@@ -1246,7 +1300,7 @@ async function login(flags) {
|
|
|
1246
1300
|
);
|
|
1247
1301
|
}
|
|
1248
1302
|
const me = await createClient(base, token).me();
|
|
1249
|
-
const where = await credentials.save(token);
|
|
1303
|
+
const where = await credentials.save(token, base);
|
|
1250
1304
|
if (isJsonMode()) {
|
|
1251
1305
|
json({ ok: true, storedIn: where, organization: me.organization, api: base });
|
|
1252
1306
|
return;
|
|
@@ -7849,7 +7903,7 @@ async function mcp(flags, which) {
|
|
|
7849
7903
|
}
|
|
7850
7904
|
|
|
7851
7905
|
// src/commands/db.ts
|
|
7852
|
-
import { randomUUID as
|
|
7906
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
7853
7907
|
import { readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
|
|
7854
7908
|
import { dirname as dirname3, isAbsolute, join as join4, resolve as resolve5 } from "node:path";
|
|
7855
7909
|
var DEFAULT_SCHEMA_FILE = "schema.json";
|
|
@@ -7886,7 +7940,7 @@ var lineageOf = (parsed) => {
|
|
|
7886
7940
|
const raw = parsed?.lineage;
|
|
7887
7941
|
return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
|
|
7888
7942
|
};
|
|
7889
|
-
var newLineage = () => `cli-${
|
|
7943
|
+
var newLineage = () => `cli-${randomUUID3()}`;
|
|
7890
7944
|
async function ensureLineage(path, parsed) {
|
|
7891
7945
|
const existing = lineageOf(parsed);
|
|
7892
7946
|
if (existing) return existing;
|