nimblybase 0.1.7 → 0.1.8

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 +21 -0
  2. package/dist/index.js +245 -148
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,6 +30,27 @@ 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
+ Inside an existing linked repository, `nimbly login --api <linked-api>` requests
34
+ access only to that project. The browser shows its authoritative name and asks
35
+ for explicit approval. The CLI verifies the server's restriction before saving
36
+ anything. Project creation, billing and other projects are excluded; your current
37
+ workspace role still limits the approved operations.
38
+
39
+ Each repository stores its grant separately in a keychain entry or under
40
+ `~/.config/nimblybase/repositories/<directory-hash>/credentials.json`. Credentials
41
+ stay outside your source tree; `nimblybase.json` only records the project and a
42
+ credential-free `authorization: "project"` marker. Nested directories use the
43
+ same login. `nimbly logout` here clears only this repository login and requires
44
+ another approval before project commands can run; your workspace login is kept.
45
+
46
+ For an unlinked existing repository, use `nimbly login --project <uuid> --api
47
+ <api>`. It links the approved project after verification. Legacy linked repos
48
+ keep their existing workspace login until they explicitly sign in this way.
49
+ Automatic restriction after creating a new project is a separate pending step.
50
+ Project login currently requires the browser flow; device sign-in and pasted
51
+ PAT login remain available outside linked repositories. An explicitly supplied
52
+ `NIMBLYBASE_TOKEN` still takes precedence for automation.
53
+
33
54
  For a machine without a local browser, run `nimbly login --device`. Open the
34
55
  displayed verification page on another device and enter the short code. The
35
56
  dashboard shows the requesting client and approved workspace/scopes. The CLI
package/dist/index.js CHANGED
@@ -185,6 +185,7 @@ USAGE
185
185
 
186
186
  GETTING STARTED
187
187
  login Sign in through your browser; --device for another machine
188
+ Inside a linked repo, approve only that project
188
189
  --token-only is the explicit pasted-PAT fallback
189
190
  init [name] Create or explicitly choose a project for this repo
190
191
  describe Show what the linked project's backend can do
@@ -373,7 +374,7 @@ function when(iso) {
373
374
 
374
375
  // src/api.ts
375
376
  var DEFAULT_API_URL = "https://api-uat.nimblybase.com";
376
- var VERSION = "0.1.7";
377
+ var VERSION = "0.1.8";
377
378
  function dashboardUrl(api) {
378
379
  const match = /^https:\/\/api(-[a-z0-9]+)?\.(.+)$/.exec(api);
379
380
  return match ? `https://app${match[1] ?? ""}.${match[2]}` : null;
@@ -808,7 +809,7 @@ async function deviceLogin(input, fetchImpl = fetch) {
808
809
  throw new Error("the device verification page is outside the authorization origin");
809
810
  }
810
811
  const now = input.now ?? (() => performance.now());
811
- const sleep = input.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
812
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve9) => setTimeout(resolve9, ms)));
812
813
  const deadline = now() + device.expires_in * 1e3;
813
814
  let interval = Math.max(5, device.interval ?? 5) * 1e3;
814
815
  input.announce({ verificationUri: verification.toString(), userCode: device.user_code, expiresInSeconds: device.expires_in });
@@ -864,6 +865,7 @@ async function browserLogin(input) {
864
865
  const port = await listener.port;
865
866
  const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
866
867
  const authorize = new URL(doc.authorization_endpoint);
868
+ if (input.projectId) authorize.searchParams.set("project_id", input.projectId);
867
869
  for (const [k, v] of Object.entries({
868
870
  response_type: "code",
869
871
  client_id: clientId,
@@ -905,7 +907,7 @@ import { promisify } from "node:util";
905
907
  import { homedir } from "node:os";
906
908
  import { join, dirname } from "node:path";
907
909
  import { mkdir, readFile, writeFile, chmod, rm, stat, rename } from "node:fs/promises";
908
- import { randomUUID } from "node:crypto";
910
+ import { createHash as createHash2, randomUUID } from "node:crypto";
909
911
  var run = promisify(execFile);
910
912
  var SERVICE = "nimblybase-cli";
911
913
  var ACCOUNT = "access-token";
@@ -914,17 +916,21 @@ function configDir() {
914
916
  return xdg ? join(xdg, "nimblybase") : join(homedir(), ".config", "nimblybase");
915
917
  }
916
918
  var credentialsPath = () => join(configDir(), "credentials.json");
919
+ function repositoryCredentials(root) {
920
+ const id = createHash2("sha256").update(root).digest("hex");
921
+ return createStore("repository-" + id, () => join(configDir(), "repositories", id, "credentials.json"), true);
922
+ }
917
923
  function keychainAvailable() {
918
924
  return process.platform === "darwin" && process.env.NIMBLYBASE_NO_KEYCHAIN !== "1";
919
925
  }
920
- async function keychainSet(secret) {
926
+ async function keychainSet(secret, account = ACCOUNT) {
921
927
  if (!keychainAvailable()) return false;
922
928
  try {
923
929
  await run("security", [
924
930
  "add-generic-password",
925
931
  "-U",
926
932
  "-a",
927
- ACCOUNT,
933
+ account,
928
934
  "-s",
929
935
  SERVICE,
930
936
  "-D",
@@ -937,20 +943,20 @@ async function keychainSet(secret) {
937
943
  return false;
938
944
  }
939
945
  }
940
- async function keychainGet() {
946
+ async function keychainGet(account = ACCOUNT) {
941
947
  if (!keychainAvailable()) return null;
942
948
  try {
943
- const { stdout } = await run("security", ["find-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-w"]);
949
+ const { stdout } = await run("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w"]);
944
950
  const value = stdout.trim();
945
951
  return value.length > 0 ? value : null;
946
952
  } catch {
947
953
  return null;
948
954
  }
949
955
  }
950
- async function keychainDelete() {
956
+ async function keychainDelete(account = ACCOUNT) {
951
957
  if (!keychainAvailable()) return false;
952
958
  try {
953
- await run("security", ["delete-generic-password", "-a", ACCOUNT, "-s", SERVICE]);
959
+ await run("security", ["delete-generic-password", "-a", account, "-s", SERVICE]);
954
960
  return true;
955
961
  } catch {
956
962
  return false;
@@ -970,15 +976,17 @@ function decodeSecret(raw) {
970
976
  return {};
971
977
  }
972
978
  }
973
- async function fileRead() {
979
+ async function fileRead(path = credentialsPath(), strict = false) {
974
980
  try {
975
- return JSON.parse(await readFile(credentialsPath(), "utf8"));
976
- } catch {
981
+ const value = JSON.parse(await readFile(path, "utf8"));
982
+ if (strict && (!value || typeof value !== "object")) throw new Error("Invalid repository credential");
983
+ return value;
984
+ } catch (err) {
985
+ if (strict && err.code !== "ENOENT") throw new Error("Repository authorization is unreadable. Run nimbly login here again; your workspace login was kept.", { cause: err });
977
986
  return null;
978
987
  }
979
988
  }
980
- async function fileWrite(value, apiUrl2) {
981
- const path = credentialsPath();
989
+ async function fileWrite(value, apiUrl2, path = credentialsPath()) {
982
990
  await mkdir(dirname(path), { recursive: true, mode: 448 });
983
991
  await chmod(dirname(path), 448);
984
992
  const body = typeof value === "string" ? { token: value, ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : { grant: value };
@@ -992,110 +1000,67 @@ async function fileWrite(value, apiUrl2) {
992
1000
  await rm(temporary, { force: true });
993
1001
  }
994
1002
  }
995
- var credentials = {
996
- async save(token, apiUrl2) {
997
- if (await keychainSet(encodeSecret(token, apiUrl2))) return "keychain";
998
- await fileWrite(token, apiUrl2);
999
- return "file";
1000
- },
1001
- async saveGrant(grant) {
1002
- if (await keychainSet(encodeSecret(grant))) return "keychain";
1003
- await fileWrite(grant);
1004
- return "file";
1005
- },
1006
- async load() {
1007
- const fromEnv = process.env.NIMBLYBASE_TOKEN?.trim();
1008
- if (fromEnv) return { token: fromEnv, where: "environment" };
1009
- const fromKeychain = await keychainGet();
1010
- if (fromKeychain) {
1011
- const { token, grant, apiUrl: apiUrl3 } = decodeSecret(fromKeychain);
1012
- const value2 = token ?? grant?.accessToken;
1013
- if (value2) return { token: value2, where: "keychain", ...apiUrl3 || grant?.issuer ? { apiUrl: apiUrl3 ?? grant.issuer } : {} };
1014
- }
1015
- const fromFile = await fileRead();
1016
- const value = fromFile?.token ?? fromFile?.grant?.accessToken;
1017
- const apiUrl2 = fromFile?.apiUrl ?? fromFile?.grant?.issuer;
1018
- return value ? { token: value, where: "file", ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : null;
1019
- },
1020
- async loadGrant() {
1021
- if (process.env.NIMBLYBASE_TOKEN?.trim()) return null;
1022
- const fromKeychain = await keychainGet();
1023
- if (fromKeychain) {
1024
- const { grant } = decodeSecret(fromKeychain);
1025
- return grant ? { grant, where: "keychain" } : null;
1026
- }
1027
- const fromFile = await fileRead();
1028
- return fromFile?.grant ? { grant: fromFile.grant, where: "file" } : null;
1029
- },
1030
- async clear() {
1031
- const cleared = [];
1032
- if (await keychainDelete()) cleared.push("keychain");
1033
- try {
1034
- await stat(credentialsPath());
1035
- await rm(credentialsPath());
1036
- cleared.push("file");
1037
- } catch {
1003
+ function createStore(account = ACCOUNT, path = credentialsPath, projectOnly = false) {
1004
+ function checkProject(grant) {
1005
+ if (projectOnly && (!grant?.projectId || !grant.issuer || !grant.refreshToken || !grant.accessToken)) {
1006
+ throw new Error("Repository authorization is invalid. Run nimbly login here again; your workspace login was kept.");
1038
1007
  }
1039
- return cleared;
1040
1008
  }
1041
- };
1042
-
1043
- // src/prompt.ts
1044
- import { createInterface } from "node:readline";
1045
- async function readPipedInput() {
1046
- if (process.stdin.isTTY) return null;
1047
- const chunks = [];
1048
- for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
1049
- const value = Buffer.concat(chunks).toString("utf8").trim();
1050
- return value.length > 0 ? value : null;
1051
- }
1052
- function promptSecret(question) {
1053
- return new Promise((resolve8, reject) => {
1054
- const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
1055
- let promptShown = false;
1056
- const output = rl;
1057
- const write = output.output.write.bind(output.output);
1058
- output.output.write = (chunk) => {
1059
- if (!promptShown) {
1060
- promptShown = true;
1061
- return write(chunk);
1009
+ return {
1010
+ async save(token, apiUrl2) {
1011
+ if (projectOnly) throw new Error("Repository authorization requires a project-restricted OAuth grant");
1012
+ if (await keychainSet(encodeSecret(token, apiUrl2), account)) return "keychain";
1013
+ await fileWrite(token, apiUrl2, path());
1014
+ return "file";
1015
+ },
1016
+ async saveGrant(grant) {
1017
+ checkProject(grant);
1018
+ if (await keychainSet(encodeSecret(grant), account)) return "keychain";
1019
+ await fileWrite(grant, void 0, path());
1020
+ return "file";
1021
+ },
1022
+ async load() {
1023
+ const fromEnv = process.env.NIMBLYBASE_TOKEN?.trim();
1024
+ if (fromEnv) return { token: fromEnv, where: "environment" };
1025
+ const fromKeychain = await keychainGet(account);
1026
+ if (fromKeychain) {
1027
+ const { token, grant, apiUrl: apiUrl3 } = decodeSecret(fromKeychain);
1028
+ checkProject(grant);
1029
+ const value2 = token ?? grant?.accessToken;
1030
+ if (value2) return { token: value2, where: "keychain", ...apiUrl3 || grant?.issuer ? { apiUrl: apiUrl3 ?? grant.issuer } : {} };
1062
1031
  }
1063
- return true;
1064
- };
1065
- rl.question(question, (answer) => {
1066
- ;
1067
- output.output.write = write;
1068
- process.stderr.write("\n");
1069
- rl.close();
1070
- resolve8(answer.trim());
1071
- });
1072
- rl.on("SIGINT", () => {
1073
- rl.close();
1074
- reject(new Error("cancelled"));
1075
- });
1076
- });
1077
- }
1078
- async function confirmExact(question, expected) {
1079
- if (!process.stdin.isTTY) return false;
1080
- const rl = createInterface({ input: process.stdin, output: process.stderr });
1081
- try {
1082
- const answer = await new Promise((r) => rl.question(`${question}
1083
- Type "${expected}" to confirm: `, r));
1084
- return answer.trim() === expected;
1085
- } finally {
1086
- rl.close();
1087
- }
1088
- }
1089
- async function confirm(question) {
1090
- if (!process.stdin.isTTY) return false;
1091
- const rl = createInterface({ input: process.stdin, output: process.stderr });
1092
- try {
1093
- const answer = await new Promise((r) => rl.question(`${question} [y/N] `, r));
1094
- return /^y(es)?$/i.test(answer.trim());
1095
- } finally {
1096
- rl.close();
1097
- }
1032
+ const fromFile = await fileRead(path(), projectOnly);
1033
+ if (fromFile) checkProject(fromFile.grant);
1034
+ const value = fromFile?.token ?? fromFile?.grant?.accessToken;
1035
+ const apiUrl2 = fromFile?.apiUrl ?? fromFile?.grant?.issuer;
1036
+ return value ? { token: value, where: "file", ...apiUrl2 ? { apiUrl: apiUrl2 } : {} } : null;
1037
+ },
1038
+ async loadGrant() {
1039
+ if (process.env.NIMBLYBASE_TOKEN?.trim()) return null;
1040
+ const fromKeychain = await keychainGet(account);
1041
+ if (fromKeychain) {
1042
+ const { grant } = decodeSecret(fromKeychain);
1043
+ checkProject(grant);
1044
+ return grant ? { grant, where: "keychain" } : null;
1045
+ }
1046
+ const fromFile = await fileRead(path(), projectOnly);
1047
+ if (fromFile) checkProject(fromFile.grant);
1048
+ return fromFile?.grant ? { grant: fromFile.grant, where: "file" } : null;
1049
+ },
1050
+ async clear() {
1051
+ const cleared = [];
1052
+ if (await keychainDelete(account)) cleared.push("keychain");
1053
+ try {
1054
+ await stat(path());
1055
+ await rm(path());
1056
+ cleared.push("file");
1057
+ } catch {
1058
+ }
1059
+ return cleared;
1060
+ }
1061
+ };
1098
1062
  }
1063
+ var credentials = createStore();
1099
1064
 
1100
1065
  // src/link.ts
1101
1066
  import { readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2, link as hardLink } from "node:fs/promises";
@@ -1146,6 +1111,7 @@ async function findLink(from = process.cwd()) {
1146
1111
  projectId: parsed.projectId,
1147
1112
  slug: typeof parsed.slug === "string" ? parsed.slug : "",
1148
1113
  apiUrl: typeof parsed.apiUrl === "string" ? parsed.apiUrl : void 0,
1114
+ ...parsed.authorization === "project" ? { authorization: "project" } : {},
1149
1115
  path
1150
1116
  };
1151
1117
  }
@@ -1181,6 +1147,7 @@ async function writeLink(dir, link2, options = {}) {
1181
1147
  slug: link2.slug
1182
1148
  };
1183
1149
  if (link2.apiUrl !== void 0) next.apiUrl = link2.apiUrl;
1150
+ if (link2.authorization !== void 0) next.authorization = link2.authorization;
1184
1151
  const temporary = join2(dirname2(path), `.${LINK_FILE}.${process.pid}.${randomUUID2()}.tmp`);
1185
1152
  try {
1186
1153
  await writeFile2(temporary, `${JSON.stringify(next, null, 2)}
@@ -1236,12 +1203,86 @@ async function clearPendingBootstrap(dir) {
1236
1203
  await rm2(join2(dir, BOOTSTRAP_FILE), { force: true });
1237
1204
  }
1238
1205
 
1206
+ // src/commands/auth.ts
1207
+ import { realpath as realpath2 } from "node:fs/promises";
1208
+ import { dirname as dirname4, resolve as resolve2 } from "node:path";
1209
+
1210
+ // src/prompt.ts
1211
+ import { createInterface } from "node:readline";
1212
+ async function readPipedInput() {
1213
+ if (process.stdin.isTTY) return null;
1214
+ const chunks = [];
1215
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
1216
+ const value = Buffer.concat(chunks).toString("utf8").trim();
1217
+ return value.length > 0 ? value : null;
1218
+ }
1219
+ function promptSecret(question) {
1220
+ return new Promise((resolve9, reject) => {
1221
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
1222
+ let promptShown = false;
1223
+ const output = rl;
1224
+ const write = output.output.write.bind(output.output);
1225
+ output.output.write = (chunk) => {
1226
+ if (!promptShown) {
1227
+ promptShown = true;
1228
+ return write(chunk);
1229
+ }
1230
+ return true;
1231
+ };
1232
+ rl.question(question, (answer) => {
1233
+ ;
1234
+ output.output.write = write;
1235
+ process.stderr.write("\n");
1236
+ rl.close();
1237
+ resolve9(answer.trim());
1238
+ });
1239
+ rl.on("SIGINT", () => {
1240
+ rl.close();
1241
+ reject(new Error("cancelled"));
1242
+ });
1243
+ });
1244
+ }
1245
+ async function confirmExact(question, expected) {
1246
+ if (!process.stdin.isTTY) return false;
1247
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1248
+ try {
1249
+ const answer = await new Promise((r) => rl.question(`${question}
1250
+ Type "${expected}" to confirm: `, r));
1251
+ return answer.trim() === expected;
1252
+ } finally {
1253
+ rl.close();
1254
+ }
1255
+ }
1256
+ async function confirm(question) {
1257
+ if (!process.stdin.isTTY) return false;
1258
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1259
+ try {
1260
+ const answer = await new Promise((r) => rl.question(`${question} [y/N] `, r));
1261
+ return /^y(es)?$/i.test(answer.trim());
1262
+ } finally {
1263
+ rl.close();
1264
+ }
1265
+ }
1266
+
1239
1267
  // src/context.ts
1268
+ import { realpath } from "node:fs/promises";
1269
+ import { dirname as dirname3 } from "node:path";
1240
1270
  async function context(apiOverride) {
1241
1271
  const link2 = await findLink();
1242
- const storedBeforeRefresh = await credentials.load();
1272
+ const repositoryStore = link2 ? repositoryCredentials(await realpath(dirname3(link2.path))) : void 0;
1273
+ const repositoryToken = await repositoryStore?.load();
1274
+ const useRepository = repositoryToken?.where !== "environment" && (Boolean(repositoryToken) || link2?.authorization === "project");
1275
+ const store = useRepository ? repositoryStore : credentials;
1276
+ const storedBeforeRefresh = useRepository ? repositoryToken : await credentials.load();
1243
1277
  const base = apiUrl(apiOverride ?? process.env.NIMBLYBASE_API_URL ?? link2?.apiUrl ?? (storedBeforeRefresh?.where !== "environment" ? storedBeforeRefresh?.apiUrl : void 0));
1244
- const held = await credentials.loadGrant();
1278
+ const held = await store.loadGrant();
1279
+ if (useRepository && held && held.grant.projectId !== link2?.projectId) {
1280
+ throw new CliError(
1281
+ "This repository login belongs to a different project.",
1282
+ EXIT.unauthenticated,
1283
+ "Run `nimbly login` here to approve the new project. Your other logins were kept."
1284
+ );
1285
+ }
1245
1286
  if (storedBeforeRefresh && storedBeforeRefresh.where !== "environment") {
1246
1287
  const bound = apiUrl(storedBeforeRefresh.apiUrl ?? "https://api-uat.nimblybase.com");
1247
1288
  if (base !== bound) throw new CliError(
@@ -1264,11 +1305,11 @@ async function context(apiOverride) {
1264
1305
  clientId: held.grant.clientId,
1265
1306
  refreshToken: held.grant.refreshToken
1266
1307
  });
1267
- await credentials.saveGrant({ ...fresh, issuer: held.grant.issuer });
1308
+ await store.saveGrant({ ...fresh, issuer: held.grant.issuer, ...held.grant.projectId ? { projectId: held.grant.projectId } : {} });
1268
1309
  } catch {
1269
1310
  }
1270
1311
  }
1271
- const stored = await credentials.load();
1312
+ const stored = await store.load();
1272
1313
  if (!stored) {
1273
1314
  throw new CliError(
1274
1315
  "Not signed in.",
@@ -1281,7 +1322,8 @@ async function context(apiOverride) {
1281
1322
  base,
1282
1323
  tokenFrom: stored.where,
1283
1324
  isCurrentToken: (prefix) => prefix.length > 0 && stored.token.startsWith(prefix),
1284
- link: link2
1325
+ link: link2,
1326
+ credentialScope: useRepository ? "repository" : "workspace"
1285
1327
  };
1286
1328
  }
1287
1329
  var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -1322,7 +1364,34 @@ var WHERE_SAID = {
1322
1364
  };
1323
1365
  async function login(flags) {
1324
1366
  const base = apiUrl(flags.api);
1367
+ const link2 = await findLink(flags.dir);
1368
+ const projectId = flags.project ?? link2?.projectId;
1369
+ if (projectId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
1370
+ throw new CliError("Project authorization requires a project UUID.", EXIT.usage, "Link this repository first, or pass --project <uuid>.");
1371
+ }
1372
+ if (projectId && flags.device) throw new CliError(
1373
+ "Project sign-in currently requires browser approval.",
1374
+ EXIT.usage,
1375
+ "Run `nimbly login` here without --device. Device sign-in outside linked repositories remains workspace-scoped."
1376
+ );
1377
+ if (projectId && link2?.apiUrl && apiUrl(link2.apiUrl) !== base) throw new CliError(
1378
+ "Choose the linked project API explicitly.",
1379
+ EXIT.usage,
1380
+ "Pass --api with the linked API; a repository file cannot choose your login destination."
1381
+ );
1382
+ if (link2 && projectId !== link2.projectId && !flags.relink) throw new CliError(
1383
+ "This repository is linked to another project.",
1384
+ EXIT.usage,
1385
+ "Use --relink to explicitly authorize and link a different project."
1386
+ );
1387
+ const root = projectId ? await realpath2(link2 ? dirname4(link2.path) : resolve2(flags.dir ?? ".")) : void 0;
1388
+ const store = root ? repositoryCredentials(root) : credentials;
1325
1389
  const given = flags.token ?? await readPipedInput();
1390
+ if (projectId && (given || flags.tokenOnly)) throw new CliError(
1391
+ "A pasted token does not authorize a repository project.",
1392
+ EXIT.usage,
1393
+ "Use browser sign-in here; use NIMBLYBASE_TOKEN for an explicitly configured automation identity."
1394
+ );
1326
1395
  if (flags.device && (given || flags.tokenOnly)) throw new CliError("Choose device sign-in or a pasted token, not both.", EXIT.usage);
1327
1396
  if (!given && !flags.tokenOnly) {
1328
1397
  const grant = flags.device ? await deviceLogin({
@@ -1340,17 +1409,40 @@ It expires in ${expiresInSeconds} seconds. Approve only the CLI request you star
1340
1409
  note(`If it did not open, go to:
1341
1410
  ${url}`);
1342
1411
  },
1343
- open: openBrowser
1412
+ open: openBrowser,
1413
+ ...projectId ? { projectId } : {}
1344
1414
  });
1345
- const me2 = await createClient(base, grant.accessToken).me();
1346
- const where2 = await credentials.saveGrant({ ...grant, issuer: base });
1415
+ const client = createClient(base, grant.accessToken);
1416
+ const me2 = await client.me();
1417
+ if (projectId && (me2.authorization?.via !== "oauth" || me2.authorization.projectId !== projectId)) {
1418
+ throw new CliError(
1419
+ "The server did not confirm this project restriction.",
1420
+ EXIT.unauthenticated,
1421
+ "Nothing was saved. Update the server/client and approve project access again."
1422
+ );
1423
+ }
1424
+ if (projectId && root) {
1425
+ const { project } = await client.project(projectId);
1426
+ if (project.id !== projectId) throw new CliError("The authorized project response is invalid.", EXIT.unauthenticated);
1427
+ await writeLink(root, { projectId, slug: project.slug, apiUrl: base, authorization: "project" }, { relink: flags.relink });
1428
+ }
1429
+ const where2 = await store.saveGrant({ ...grant, issuer: base, ...projectId ? { projectId } : {} });
1347
1430
  if (isJsonMode()) {
1348
- json({ ok: true, storedIn: where2, organization: me2.organization, api: base, scopes: grant.scopes });
1431
+ json({
1432
+ ok: true,
1433
+ storedIn: where2,
1434
+ organization: me2.organization,
1435
+ api: base,
1436
+ scopes: grant.scopes,
1437
+ authorizationScope: projectId ? "repository" : "workspace",
1438
+ ...projectId ? { projectId } : {}
1439
+ });
1349
1440
  return;
1350
1441
  }
1351
1442
  out(`${paint("green", "\u2713")} Signed in as ${paint("bold", me2.organization?.name ?? "your account")}${me2.organization ? ` (${me2.organization.role})` : ""}`);
1352
1443
  note(`Granted: ${grant.scopes.join(", ") || "no scopes"}.`);
1353
- note(`Stored in ${WHERE_SAID[where2]}. It refreshes itself; run \`nimbly logout\` to end it.`);
1444
+ note(projectId ? `Access is restricted to ${projectId}. Stored separately for this repository ${where2 === "keychain" ? "in your login keychain" : "outside the repository (permissions 0600)"}.` : `Stored in ${WHERE_SAID[where2]}.`);
1445
+ note("It refreshes itself; run `nimbly logout` here to clear this login.");
1354
1446
  return;
1355
1447
  }
1356
1448
  const token = given ?? await promptSecret(source(base));
@@ -1374,14 +1466,18 @@ It expires in ${expiresInSeconds} seconds. Approve only the CLI request you star
1374
1466
  note("The keychain was not available, so a file was used instead.");
1375
1467
  }
1376
1468
  }
1377
- async function logout() {
1378
- const cleared = await credentials.clear();
1469
+ async function logout(flags) {
1470
+ const link2 = await findLink(flags?.dir);
1471
+ const repo = link2 ? repositoryCredentials(await realpath2(dirname4(link2.path))) : void 0;
1472
+ const repository = link2?.authorization === "project" || repo && await repo.loadGrant() !== null;
1473
+ const cleared = await (repository ? repo : credentials).clear();
1379
1474
  if (isJsonMode()) {
1380
1475
  json({ ok: true, cleared });
1381
1476
  return;
1382
1477
  }
1383
1478
  if (cleared.length === 0) out("Nothing stored - already signed out.");
1384
1479
  else out(`${paint("green", "\u2713")} Signed out (cleared: ${cleared.join(", ")}).`);
1480
+ if (repository) note("Cleared this repository login. Your separate workspace login was kept; run logout outside this repository to clear it.");
1385
1481
  if (process.env.NIMBLYBASE_TOKEN) {
1386
1482
  note("NIMBLYBASE_TOKEN is still set in this shell and takes precedence - unset it too.");
1387
1483
  }
@@ -1390,7 +1486,7 @@ async function whoami(flags) {
1390
1486
  const ctx = await context(flags.api);
1391
1487
  const me = await ctx.client.me();
1392
1488
  if (isJsonMode()) {
1393
- json({ ...me, api: ctx.base, tokenFrom: ctx.tokenFrom, link: ctx.link });
1489
+ json({ ...me, api: ctx.base, tokenFrom: ctx.tokenFrom, link: ctx.link, credentialScope: ctx.credentialScope });
1394
1490
  return;
1395
1491
  }
1396
1492
  table(["", ""], [
@@ -1399,6 +1495,7 @@ async function whoami(flags) {
1399
1495
  ["role", me.organization?.role ?? "-"],
1400
1496
  ["api", ctx.base],
1401
1497
  ["token from", ctx.tokenFrom],
1498
+ ["credential scope", ctx.credentialScope],
1402
1499
  ["linked project", ctx.link ? `${ctx.link.slug || ctx.link.projectId} \xB7 ${ctx.link.path}` : "none"]
1403
1500
  ]);
1404
1501
  out();
@@ -1412,8 +1509,8 @@ var AFTER_CREATE = [
1412
1509
  ];
1413
1510
 
1414
1511
  // src/creation-plan.ts
1415
- import { createHash as createHash2 } from "node:crypto";
1416
- import { resolve as resolve3 } from "node:path";
1512
+ import { createHash as createHash3 } from "node:crypto";
1513
+ import { resolve as resolve4 } from "node:path";
1417
1514
 
1418
1515
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
1419
1516
  var external_exports = {};
@@ -7315,7 +7412,7 @@ function staticSite(entries) {
7315
7412
 
7316
7413
  // src/commands/assess.ts
7317
7414
  import { stat as stat3 } from "node:fs/promises";
7318
- import { basename, resolve as resolve2 } from "node:path";
7415
+ import { basename, resolve as resolve3 } from "node:path";
7319
7416
 
7320
7417
  // src/repository-scan.ts
7321
7418
  import { open, readdir, stat as stat2 } from "node:fs/promises";
@@ -7554,7 +7651,7 @@ async function assessWithContext(ctx, flags, requirements = "") {
7554
7651
  return;
7555
7652
  }
7556
7653
  if (flags.savePlan && !flags.name) throw new CliError("--save-plan requires --name with an explicit project name.", EXIT.usage);
7557
- const facts = await inspectAssessmentFacts(resolve2(flags.dir ?? "."), flags, requirements);
7654
+ const facts = await inspectAssessmentFacts(resolve3(flags.dir ?? "."), flags, requirements);
7558
7655
  if (flags.savePlan) {
7559
7656
  const result2 = ProjectPlanResponse.safeParse(await ctx.client.createProjectPlan({ name: flags.name, ...flags.slug ? { slug: flags.slug } : {}, facts }));
7560
7657
  if (!result2.success) throw new CliError("The API returned an invalid project plan.", EXIT.failed);
@@ -7585,7 +7682,7 @@ async function assess(flags, ...words) {
7585
7682
 
7586
7683
  // src/creation-plan.ts
7587
7684
  async function prepareCreation(ctx, flags, name, operation) {
7588
- const root = resolve3(flags.dir ?? ".");
7685
+ const root = resolve4(flags.dir ?? ".");
7589
7686
  const me = await ctx.client.me();
7590
7687
  if (!me.organization) throw new CliError("Select a workspace before creating a project.", EXIT.forbidden);
7591
7688
  const previous = await readPendingBootstrap(root);
@@ -7609,7 +7706,7 @@ async function prepareCreation(ctx, flags, name, operation) {
7609
7706
  EXIT.usage,
7610
7707
  "Run nimbly assess to clarify the requirements. An empty new application needs the explicit --recipe static-d1 choice."
7611
7708
  );
7612
- const fingerprint = createHash2("sha256").update(JSON.stringify(SuitabilityFacts.parse(facts))).digest("hex");
7709
+ const fingerprint = createHash3("sha256").update(JSON.stringify(SuitabilityFacts.parse(facts))).digest("hex");
7613
7710
  if (previous) {
7614
7711
  if (flags.plan && flags.plan !== previous.planId) throw new CliError("Retry with the original pending plan.", EXIT.usage);
7615
7712
  if (fingerprint !== previous.factsFingerprint) throw new CliError(
@@ -7641,7 +7738,7 @@ async function prepareCreation(ctx, flags, name, operation) {
7641
7738
  }
7642
7739
 
7643
7740
  // src/commands/projects.ts
7644
- import { resolve as resolve4 } from "node:path";
7741
+ import { resolve as resolve5 } from "node:path";
7645
7742
  var WAIT_TIMEOUT_MS = 18e4;
7646
7743
  var FIRST_POLL_MS = 500;
7647
7744
  var POLL_GROWTH = 1.5;
@@ -7699,7 +7796,7 @@ async function create(flags, name) {
7699
7796
  );
7700
7797
  }
7701
7798
  const ctx = await context(flags.api);
7702
- const root = resolve4(flags.dir ?? ".");
7799
+ const root = resolve5(flags.dir ?? ".");
7703
7800
  if (flags.link !== false) {
7704
7801
  await assertMayCreateLink(root, { relink: flags.relink });
7705
7802
  }
@@ -7751,7 +7848,7 @@ async function init(flags, name) {
7751
7848
  );
7752
7849
  }
7753
7850
  const ctx = await context(flags.api);
7754
- const root = resolve4(flags.dir ?? ".");
7851
+ const root = resolve5(flags.dir ?? ".");
7755
7852
  const explicitProject = flags.project;
7756
7853
  const linkedProject = !name && !explicitProject ? (await findLink(root))?.projectId : void 0;
7757
7854
  if (name && flags.link !== false) {
@@ -8025,11 +8122,11 @@ async function mcp(flags, which) {
8025
8122
  // src/commands/db.ts
8026
8123
  import { randomUUID as randomUUID3 } from "node:crypto";
8027
8124
  import { readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
8028
- import { dirname as dirname3, isAbsolute, join as join4, resolve as resolve5 } from "node:path";
8125
+ import { dirname as dirname5, isAbsolute, join as join4, resolve as resolve6 } from "node:path";
8029
8126
  var DEFAULT_SCHEMA_FILE = "schema.json";
8030
8127
  function schemaPath(ctx, flag) {
8031
- if (flag) return isAbsolute(flag) ? flag : resolve5(process.cwd(), flag);
8032
- const base = ctx.link ? dirname3(ctx.link.path) : process.cwd();
8128
+ if (flag) return isAbsolute(flag) ? flag : resolve6(process.cwd(), flag);
8129
+ const base = ctx.link ? dirname5(ctx.link.path) : process.cwd();
8033
8130
  return join4(base, DEFAULT_SCHEMA_FILE);
8034
8131
  }
8035
8132
  async function readSchema(path) {
@@ -8539,7 +8636,7 @@ function render4(f) {
8539
8636
 
8540
8637
  // src/commands/functions.ts
8541
8638
  import { readFile as readFile5 } from "node:fs/promises";
8542
- import { basename as basename2, dirname as dirname4, extname as extname2, resolve as resolve6 } from "node:path";
8639
+ import { basename as basename2, dirname as dirname6, extname as extname2, resolve as resolve7 } from "node:path";
8543
8640
  import { build as bundle } from "esbuild";
8544
8641
  var NAME = /^[a-z][a-z0-9-]{0,30}$/;
8545
8642
  var MAX_FUNCTION_BYTES = 1e6;
@@ -8620,10 +8717,10 @@ async function deploy(flags, file) {
8620
8717
  note(paint("dim", " env.DB, env.FILES, env.KV are this project\u2019s own. Nothing else is reachable."));
8621
8718
  }
8622
8719
  async function bundleFunction(file) {
8623
- const absolute = resolve6(file);
8720
+ const absolute = resolve7(file);
8624
8721
  try {
8625
8722
  const result = await bundle({
8626
- absWorkingDir: dirname4(absolute),
8723
+ absWorkingDir: dirname6(absolute),
8627
8724
  entryPoints: [absolute],
8628
8725
  bundle: true,
8629
8726
  write: false,
@@ -9067,7 +9164,7 @@ function propertyName(name) {
9067
9164
 
9068
9165
  // src/commands/deploy.ts
9069
9166
  import { readdir as readdir2, readFile as readFile6, stat as stat4 } from "node:fs/promises";
9070
- import { createHash as createHash3 } from "node:crypto";
9167
+ import { createHash as createHash4 } from "node:crypto";
9071
9168
  import { spawn as spawn2 } from "node:child_process";
9072
9169
  import { join as join5, relative as relative2, sep } from "node:path";
9073
9170
  var SKIP = /* @__PURE__ */ new Set([".git", "node_modules", ".DS_Store", ".env", ".env.local", "Thumbs.db"]);
@@ -9238,7 +9335,7 @@ async function buildSite(root, script, display) {
9238
9335
  const entries = await readdir2(root);
9239
9336
  const runner = packageRunner(entries);
9240
9337
  if (!isJsonMode()) note(`Running ${runner.label} run ${script}\u2026`);
9241
- const code = await new Promise((resolve8, reject) => {
9338
+ const code = await new Promise((resolve9, reject) => {
9242
9339
  const child = spawn2(runner.command, [...runner.args, "run", script], {
9243
9340
  cwd: root,
9244
9341
  stdio: "inherit",
@@ -9248,7 +9345,7 @@ async function buildSite(root, script, display) {
9248
9345
  shell: process.platform === "win32"
9249
9346
  });
9250
9347
  child.once("error", reject);
9251
- child.once("close", (status4) => resolve8(status4 ?? 1));
9348
+ child.once("close", (status4) => resolve9(status4 ?? 1));
9252
9349
  }).catch((err) => {
9253
9350
  throw new CliError(
9254
9351
  `Could not start ${display ?? `${runner.label} run ${script}`}.`,
@@ -9308,7 +9405,7 @@ async function collect(dir) {
9308
9405
  found.push({
9309
9406
  path: toUrlPath(relative2(dir, absolute)),
9310
9407
  absolute,
9311
- hash: createHash3("sha256").update(await readFile6(absolute)).digest("hex"),
9408
+ hash: createHash4("sha256").update(await readFile6(absolute)).digest("hex"),
9312
9409
  size: info.size
9313
9410
  });
9314
9411
  }
@@ -9778,7 +9875,7 @@ async function remove4(flags, name) {
9778
9875
 
9779
9876
  // src/commands/architect.ts
9780
9877
  import { readdir as readdir3, readFile as readFile8, stat as stat5 } from "node:fs/promises";
9781
- import { join as join6, resolve as resolve7 } from "node:path";
9878
+ import { join as join6, resolve as resolve8 } from "node:path";
9782
9879
  var FEATURE_PATTERNS = {
9783
9880
  auth: /\b(auth(?:entication|enticated|orization|orized)?|log-?ins?|sign-?ins?|sign-?ups?|sessions?|user accounts?|passport|clerk|auth0)\b/i,
9784
9881
  storage: /\b(uploads?|attachments?|buckets?|object storage|file (?:uploads?|attachments?|storage)|s3|r2|multer|media)\b/i,
@@ -9792,7 +9889,7 @@ var FEATURE_PATTERNS = {
9792
9889
  crawler: /\b(crawls?|crawlers?|scrapes?|playwright|puppeteer|browser automation)\b/i
9793
9890
  };
9794
9891
  async function architect(flags, ...words) {
9795
- const root = resolve7(flags.dir ?? ".");
9892
+ const root = resolve8(flags.dir ?? ".");
9796
9893
  const requirements = words.filter((word) => Boolean(word)).join(" ").trim();
9797
9894
  const ctx = await context(flags.api);
9798
9895
  if (!flags.project && !ctx.link) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimblybase",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "The NimblyBase command line - projects, keys and backends from a terminal.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {