nimblybase 0.1.6 → 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 +27 -0
  2. package/dist/index.js +306 -152
  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
@@ -252,3 +273,9 @@ nimbly sql "select * from notes where id = ?" --param 7
252
273
  go into the statement string: a CLI whose only option is to paste them in
253
274
  teaches everybody — and every agent reading a shell history — to write
254
275
  injectable SQL.
276
+
277
+ Public frontend keys can be issued after browser/device login with
278
+ `nimbly keys create "web frontend" --type anon` (requires `projects:write`).
279
+ They obey row and storage policies. `--type service`, key listing and revocation
280
+ require separate session/PAT key-management authority and remain unavailable
281
+ through OAuth. Never put the agent’s OAuth credential in application code.
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.6";
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;
@@ -473,8 +474,8 @@ function createClient(base, token) {
473
474
  resumeProject: (id) => req(`/v1/projects/${id}/resume`, { method: "POST", body: "{}" }),
474
475
  keys: (projectId) => req(`/v1/projects/${projectId}/keys`),
475
476
  createKey: (projectId, input) => req(
476
- `/v1/projects/${projectId}/keys`,
477
- { method: "POST", body: JSON.stringify(input) }
477
+ `/v1/projects/${projectId}/${input.type === "anon" ? "application-keys" : "keys"}`,
478
+ { method: "POST", body: JSON.stringify(input.type === "anon" ? { name: input.name } : input) }
478
479
  ),
479
480
  revokeKey: (keyId) => req(`/v1/keys/${keyId}`, { method: "DELETE" }),
480
481
  // --- project data ---------------------------------------------------
@@ -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 = {};
@@ -5585,6 +5682,7 @@ var PendingInvitation = external_exports.object({
5585
5682
  expiresAt: IsoDateTime
5586
5683
  });
5587
5684
  var Me = external_exports.object({
5685
+ authorization: external_exports.object({ via: external_exports.enum(["session", "token", "oauth"]), projectId: external_exports.string().uuid().nullable() }).optional(),
5588
5686
  user: external_exports.object({ id: Uuid, clerkUserId: external_exports.string() }),
5589
5687
  organization: external_exports.object({ id: Uuid, name: external_exports.string(), slug: external_exports.string(), role: Role }).nullable(),
5590
5688
  permissions: external_exports.array(external_exports.string())
@@ -5665,6 +5763,9 @@ var BootstrapBody = external_exports.object({
5665
5763
  }).refine((body) => !(body.planId || body.factsFingerprint) || Boolean(body.name), {
5666
5764
  message: "planId and factsFingerprint are only valid when creating a named project"
5667
5765
  });
5766
+ var CreateApplicationKeyBody = external_exports.object({
5767
+ name: external_exports.string().min(1).max(100)
5768
+ }).strict();
5668
5769
  var CreateApiKeyBody = external_exports.object({
5669
5770
  name: external_exports.string().min(1).max(100),
5670
5771
  type: ApiKeyType
@@ -5866,6 +5967,20 @@ var PendingInvitationListResponse = external_exports.object({ invitations: exter
5866
5967
  var RedeemedInvitationResponse = external_exports.object({
5867
5968
  organization: external_exports.object({ id: Uuid, role: Role })
5868
5969
  });
5970
+ var OAuthConnection = external_exports.object({
5971
+ id: external_exports.string().uuid(),
5972
+ clientId: external_exports.string().uuid(),
5973
+ clientName: external_exports.string(),
5974
+ orgId: external_exports.string().uuid(),
5975
+ projectId: external_exports.string().uuid().nullable(),
5976
+ projectName: external_exports.string().nullable(),
5977
+ scopes: external_exports.array(external_exports.string()),
5978
+ status: external_exports.enum(["ready", "awaiting_exchange"]),
5979
+ connectedAt: external_exports.string().datetime(),
5980
+ expiresAt: external_exports.string().datetime(),
5981
+ lastUsedAt: external_exports.string().datetime().nullable()
5982
+ });
5983
+ var OAuthConnectionListResponse = external_exports.object({ enabled: external_exports.boolean(), connections: external_exports.array(OAuthConnection) });
5869
5984
 
5870
5985
  // ../../node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js
5871
5986
  var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use");
@@ -6053,6 +6168,34 @@ var routes = {
6053
6168
  // All three are sessionOnly: a token must not be able to mint another
6054
6169
  // token. Otherwise a leaked one manufactures replacements faster than
6055
6170
  // anyone can revoke them, and revoking the one you found achieves nothing.
6171
+ listOAuthConnections: {
6172
+ method: "GET",
6173
+ path: "/v1/me/connections",
6174
+ summary: "Your authorized app connections in this workspace. No credentials.",
6175
+ permission: "org:read",
6176
+ sessionOnly: true,
6177
+ status: 200,
6178
+ response: OAuthConnectionListResponse
6179
+ },
6180
+ narrowOAuthConnection: {
6181
+ method: "POST",
6182
+ path: "/v1/me/connections/{id}/project",
6183
+ summary: "Permanently restrict one authorization to an existing project. Cannot widen or retarget.",
6184
+ permission: "org:read",
6185
+ sessionOnly: true,
6186
+ params: Id,
6187
+ body: external_exports.object({ projectId: external_exports.string().uuid() }).strict(),
6188
+ status: 204
6189
+ },
6190
+ revokeOAuthConnection: {
6191
+ method: "DELETE",
6192
+ path: "/v1/me/connections/{id}",
6193
+ summary: "Disconnect one consent family, including access, refresh and pending exchange.",
6194
+ permission: "org:read",
6195
+ sessionOnly: true,
6196
+ params: Id,
6197
+ status: 204
6198
+ },
6056
6199
  listAccessTokens: {
6057
6200
  method: "GET",
6058
6201
  path: "/v1/me/tokens",
@@ -6216,6 +6359,16 @@ var routes = {
6216
6359
  status: 200,
6217
6360
  response: ApiKeyListResponse
6218
6361
  },
6362
+ createApplicationKey: {
6363
+ method: "POST",
6364
+ path: "/v1/projects/{id}/application-keys",
6365
+ summary: "Issue a public anon application key. It obeys row and storage policies; never issues a service key.",
6366
+ permission: "project:update",
6367
+ params: Id,
6368
+ body: CreateApplicationKeyBody,
6369
+ status: 201,
6370
+ response: IssuedApiKeyResponse
6371
+ },
6219
6372
  createProjectKey: {
6220
6373
  method: "POST",
6221
6374
  path: "/v1/projects/{id}/keys",
@@ -6236,8 +6389,9 @@ var routes = {
6236
6389
  // The platform token never leaves the control plane and expires after 60
6237
6390
  // seconds. Callers are therefore authorized for the operation they asked
6238
6391
  // for (`data:read`, `data:write`, `deploy`, or `logs:read`) rather than for
6239
- // the internal credential used to carry it out. Actual API-key management
6240
- // remains under apikey:* and is deliberately closed to OAuth.
6392
+ // the internal credential used to carry it out. Service-key issuance, key
6393
+ // listing and revocation remain under apikey:* and closed to OAuth. The
6394
+ // separate anon-only application-key route above uses project:update.
6241
6395
  getProjectSchema: {
6242
6396
  method: "GET",
6243
6397
  path: "/v1/projects/{id}/schema",
@@ -7258,7 +7412,7 @@ function staticSite(entries) {
7258
7412
 
7259
7413
  // src/commands/assess.ts
7260
7414
  import { stat as stat3 } from "node:fs/promises";
7261
- import { basename, resolve as resolve2 } from "node:path";
7415
+ import { basename, resolve as resolve3 } from "node:path";
7262
7416
 
7263
7417
  // src/repository-scan.ts
7264
7418
  import { open, readdir, stat as stat2 } from "node:fs/promises";
@@ -7497,7 +7651,7 @@ async function assessWithContext(ctx, flags, requirements = "") {
7497
7651
  return;
7498
7652
  }
7499
7653
  if (flags.savePlan && !flags.name) throw new CliError("--save-plan requires --name with an explicit project name.", EXIT.usage);
7500
- const facts = await inspectAssessmentFacts(resolve2(flags.dir ?? "."), flags, requirements);
7654
+ const facts = await inspectAssessmentFacts(resolve3(flags.dir ?? "."), flags, requirements);
7501
7655
  if (flags.savePlan) {
7502
7656
  const result2 = ProjectPlanResponse.safeParse(await ctx.client.createProjectPlan({ name: flags.name, ...flags.slug ? { slug: flags.slug } : {}, facts }));
7503
7657
  if (!result2.success) throw new CliError("The API returned an invalid project plan.", EXIT.failed);
@@ -7528,7 +7682,7 @@ async function assess(flags, ...words) {
7528
7682
 
7529
7683
  // src/creation-plan.ts
7530
7684
  async function prepareCreation(ctx, flags, name, operation) {
7531
- const root = resolve3(flags.dir ?? ".");
7685
+ const root = resolve4(flags.dir ?? ".");
7532
7686
  const me = await ctx.client.me();
7533
7687
  if (!me.organization) throw new CliError("Select a workspace before creating a project.", EXIT.forbidden);
7534
7688
  const previous = await readPendingBootstrap(root);
@@ -7552,7 +7706,7 @@ async function prepareCreation(ctx, flags, name, operation) {
7552
7706
  EXIT.usage,
7553
7707
  "Run nimbly assess to clarify the requirements. An empty new application needs the explicit --recipe static-d1 choice."
7554
7708
  );
7555
- 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");
7556
7710
  if (previous) {
7557
7711
  if (flags.plan && flags.plan !== previous.planId) throw new CliError("Retry with the original pending plan.", EXIT.usage);
7558
7712
  if (fingerprint !== previous.factsFingerprint) throw new CliError(
@@ -7584,7 +7738,7 @@ async function prepareCreation(ctx, flags, name, operation) {
7584
7738
  }
7585
7739
 
7586
7740
  // src/commands/projects.ts
7587
- import { resolve as resolve4 } from "node:path";
7741
+ import { resolve as resolve5 } from "node:path";
7588
7742
  var WAIT_TIMEOUT_MS = 18e4;
7589
7743
  var FIRST_POLL_MS = 500;
7590
7744
  var POLL_GROWTH = 1.5;
@@ -7642,7 +7796,7 @@ async function create(flags, name) {
7642
7796
  );
7643
7797
  }
7644
7798
  const ctx = await context(flags.api);
7645
- const root = resolve4(flags.dir ?? ".");
7799
+ const root = resolve5(flags.dir ?? ".");
7646
7800
  if (flags.link !== false) {
7647
7801
  await assertMayCreateLink(root, { relink: flags.relink });
7648
7802
  }
@@ -7694,7 +7848,7 @@ async function init(flags, name) {
7694
7848
  );
7695
7849
  }
7696
7850
  const ctx = await context(flags.api);
7697
- const root = resolve4(flags.dir ?? ".");
7851
+ const root = resolve5(flags.dir ?? ".");
7698
7852
  const explicitProject = flags.project;
7699
7853
  const linkedProject = !name && !explicitProject ? (await findLink(root))?.projectId : void 0;
7700
7854
  if (name && flags.link !== false) {
@@ -7968,11 +8122,11 @@ async function mcp(flags, which) {
7968
8122
  // src/commands/db.ts
7969
8123
  import { randomUUID as randomUUID3 } from "node:crypto";
7970
8124
  import { readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
7971
- 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";
7972
8126
  var DEFAULT_SCHEMA_FILE = "schema.json";
7973
8127
  function schemaPath(ctx, flag) {
7974
- if (flag) return isAbsolute(flag) ? flag : resolve5(process.cwd(), flag);
7975
- 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();
7976
8130
  return join4(base, DEFAULT_SCHEMA_FILE);
7977
8131
  }
7978
8132
  async function readSchema(path) {
@@ -8482,7 +8636,7 @@ function render4(f) {
8482
8636
 
8483
8637
  // src/commands/functions.ts
8484
8638
  import { readFile as readFile5 } from "node:fs/promises";
8485
- 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";
8486
8640
  import { build as bundle } from "esbuild";
8487
8641
  var NAME = /^[a-z][a-z0-9-]{0,30}$/;
8488
8642
  var MAX_FUNCTION_BYTES = 1e6;
@@ -8563,10 +8717,10 @@ async function deploy(flags, file) {
8563
8717
  note(paint("dim", " env.DB, env.FILES, env.KV are this project\u2019s own. Nothing else is reachable."));
8564
8718
  }
8565
8719
  async function bundleFunction(file) {
8566
- const absolute = resolve6(file);
8720
+ const absolute = resolve7(file);
8567
8721
  try {
8568
8722
  const result = await bundle({
8569
- absWorkingDir: dirname4(absolute),
8723
+ absWorkingDir: dirname6(absolute),
8570
8724
  entryPoints: [absolute],
8571
8725
  bundle: true,
8572
8726
  write: false,
@@ -9010,7 +9164,7 @@ function propertyName(name) {
9010
9164
 
9011
9165
  // src/commands/deploy.ts
9012
9166
  import { readdir as readdir2, readFile as readFile6, stat as stat4 } from "node:fs/promises";
9013
- import { createHash as createHash3 } from "node:crypto";
9167
+ import { createHash as createHash4 } from "node:crypto";
9014
9168
  import { spawn as spawn2 } from "node:child_process";
9015
9169
  import { join as join5, relative as relative2, sep } from "node:path";
9016
9170
  var SKIP = /* @__PURE__ */ new Set([".git", "node_modules", ".DS_Store", ".env", ".env.local", "Thumbs.db"]);
@@ -9181,7 +9335,7 @@ async function buildSite(root, script, display) {
9181
9335
  const entries = await readdir2(root);
9182
9336
  const runner = packageRunner(entries);
9183
9337
  if (!isJsonMode()) note(`Running ${runner.label} run ${script}\u2026`);
9184
- const code = await new Promise((resolve8, reject) => {
9338
+ const code = await new Promise((resolve9, reject) => {
9185
9339
  const child = spawn2(runner.command, [...runner.args, "run", script], {
9186
9340
  cwd: root,
9187
9341
  stdio: "inherit",
@@ -9191,7 +9345,7 @@ async function buildSite(root, script, display) {
9191
9345
  shell: process.platform === "win32"
9192
9346
  });
9193
9347
  child.once("error", reject);
9194
- child.once("close", (status4) => resolve8(status4 ?? 1));
9348
+ child.once("close", (status4) => resolve9(status4 ?? 1));
9195
9349
  }).catch((err) => {
9196
9350
  throw new CliError(
9197
9351
  `Could not start ${display ?? `${runner.label} run ${script}`}.`,
@@ -9251,7 +9405,7 @@ async function collect(dir) {
9251
9405
  found.push({
9252
9406
  path: toUrlPath(relative2(dir, absolute)),
9253
9407
  absolute,
9254
- hash: createHash3("sha256").update(await readFile6(absolute)).digest("hex"),
9408
+ hash: createHash4("sha256").update(await readFile6(absolute)).digest("hex"),
9255
9409
  size: info.size
9256
9410
  });
9257
9411
  }
@@ -9721,7 +9875,7 @@ async function remove4(flags, name) {
9721
9875
 
9722
9876
  // src/commands/architect.ts
9723
9877
  import { readdir as readdir3, readFile as readFile8, stat as stat5 } from "node:fs/promises";
9724
- import { join as join6, resolve as resolve7 } from "node:path";
9878
+ import { join as join6, resolve as resolve8 } from "node:path";
9725
9879
  var FEATURE_PATTERNS = {
9726
9880
  auth: /\b(auth(?:entication|enticated|orization|orized)?|log-?ins?|sign-?ins?|sign-?ups?|sessions?|user accounts?|passport|clerk|auth0)\b/i,
9727
9881
  storage: /\b(uploads?|attachments?|buckets?|object storage|file (?:uploads?|attachments?|storage)|s3|r2|multer|media)\b/i,
@@ -9735,7 +9889,7 @@ var FEATURE_PATTERNS = {
9735
9889
  crawler: /\b(crawls?|crawlers?|scrapes?|playwright|puppeteer|browser automation)\b/i
9736
9890
  };
9737
9891
  async function architect(flags, ...words) {
9738
- const root = resolve7(flags.dir ?? ".");
9892
+ const root = resolve8(flags.dir ?? ".");
9739
9893
  const requirements = words.filter((word) => Boolean(word)).join(" ").trim();
9740
9894
  const ctx = await context(flags.api);
9741
9895
  if (!flags.project && !ctx.link) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nimblybase",
3
- "version": "0.1.6",
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": {