@gravity-rail/cli 0.53.1 → 0.55.0

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 CHANGED
@@ -40,6 +40,15 @@ own confidential client. Both kinds of profile refresh normally; nothing needs t
40
40
  be re-authorized when a server gains the pinned client, though the next
41
41
  `gr login` on that profile will switch to it.
42
42
 
43
+ Access tokens refresh automatically. When several `gr` commands on one machine
44
+ need a refresh at the same time, one of them exchanges the refresh token while
45
+ the others wait briefly and reuse the result, so parallel runs no longer sign
46
+ each other out. A refresh that fails for a temporary reason (network error,
47
+ timeout, or server error) keeps your stored credentials and exits with an
48
+ error; retry the command. Only when the server rejects the stored refresh token
49
+ is that one profile cleared, and `gr login` (or `gr auth login --name <profile>`)
50
+ signs it back in.
51
+
43
52
  ### Multi-account profiles
44
53
 
45
54
  Like `aws --profile` / `gh auth switch`, the public CLI supports multiple named accounts:
@@ -308,6 +317,7 @@ not forwarded, as it may contain sensitive data. Use `resume.chatUuid` /
308
317
  | `calendars` | Scheduling — calendars, events, event types, availability, Google sync |
309
318
  | `qualifications` | Skills evaluation — requirements, submissions, scoring |
310
319
  | `queries` | Unified Query Engine — fields, validate, preview, run, syntax reference |
320
+ | `data-reports` | Form Reports — preview a report as the Agent would run it |
311
321
 
312
322
  ### Global (no workspace)
313
323
 
@@ -2,8 +2,8 @@ import {
2
2
  confirmAccountPhoneVerify,
3
3
  requireAuth,
4
4
  startAccountPhoneVerify
5
- } from "./chunk-FQMZ2EBF.js";
6
- import "./chunk-FSY2BM4J.js";
5
+ } from "./chunk-WCMXS5CL.js";
6
+ import "./chunk-BQ3WBHSA.js";
7
7
  import "./chunk-3T7A5PSF.js";
8
8
 
9
9
  // src/commands/account-verify-phone.ts
@@ -38,4 +38,4 @@ async function promptLine(question) {
38
38
  export {
39
39
  accountVerifyPhoneCommand
40
40
  };
41
- //# sourceMappingURL=account-verify-phone-ABHHHGCC.js.map
41
+ //# sourceMappingURL=account-verify-phone-4ZN7NJGW.js.map
@@ -230,6 +230,76 @@ function getEnvironmentConfig(env, options = {}) {
230
230
  import { createServer } from "http";
231
231
  import { createHash, randomBytes } from "crypto";
232
232
  import { exec } from "child_process";
233
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
234
+ import { dirname as dirname2 } from "path";
235
+ import lockfile from "proper-lockfile";
236
+
237
+ // package.json
238
+ var package_default = {
239
+ name: "@gravity-rail/cli",
240
+ version: "0.55.0",
241
+ description: "Gravity Rail CLI \u2014 manage workspaces from the command line",
242
+ type: "module",
243
+ bin: {
244
+ gr: "./dist/gr.js"
245
+ },
246
+ exports: {
247
+ ".": {
248
+ types: "./dist/lib.d.ts",
249
+ import: "./dist/lib.js"
250
+ },
251
+ "./lib": {
252
+ types: "./dist/lib.d.ts",
253
+ import: "./dist/lib.js"
254
+ },
255
+ "./cli-utils": {
256
+ types: "./dist/cli-utils.d.ts",
257
+ import: "./dist/cli-utils.js"
258
+ }
259
+ },
260
+ files: [
261
+ "dist"
262
+ ],
263
+ license: "MIT",
264
+ repository: {
265
+ type: "git",
266
+ url: "https://developer.gravityrail.com"
267
+ },
268
+ homepage: "https://developer.gravityrail.com",
269
+ keywords: [
270
+ "gravity-rail",
271
+ "cli",
272
+ "healthcare",
273
+ "workspace"
274
+ ],
275
+ engines: {
276
+ node: ">=18"
277
+ },
278
+ scripts: {
279
+ build: "tsup",
280
+ "generate:skill-docs": "tsx scripts/generate-skill-docs.ts",
281
+ clean: "rm -rf dist",
282
+ typecheck: "tsc --noEmit",
283
+ test: "jest --forceExit",
284
+ "test:ci": "jest --forceExit --passWithNoTests --coverage --coverageReporters=json-summary --coverageReporters=text-summary"
285
+ },
286
+ dependencies: {
287
+ "@gravity-rail/sdk": "workspace:^",
288
+ "js-yaml": "^5.4.3",
289
+ "proper-lockfile": "^4.1.2"
290
+ },
291
+ devDependencies: {
292
+ "@gravity-rail/sandbox": "workspace:*",
293
+ "@types/jest": "^30.0.0",
294
+ "@types/node": "^26.6.4",
295
+ "@types/proper-lockfile": "^4.1.4",
296
+ jest: "^30.5.2",
297
+ "ts-jest": "29.4.9",
298
+ tsup: "^8.5.1",
299
+ typescript: "^6.0.3",
300
+ undici: "6.28.1"
301
+ }
302
+ };
233
303
 
234
304
  // src/cli-utils/credentials-manager.ts
235
305
  import {
@@ -582,6 +652,15 @@ var CredentialsManager = class {
582
652
 
583
653
  // src/cli-utils/auth.ts
584
654
  var ACCESS_TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1e3;
655
+ var TOKEN_REQUEST_USER_AGENT = `gravityrail-cli/${package_default.version}`;
656
+ var REFRESH_REQUEST_TIMEOUT_MS = 15e3;
657
+ var REFRESH_LOCK_RETRIES = { retries: 23, factor: 2, minTimeout: 100, maxTimeout: 1e3 };
658
+ var REFRESH_LOCK_FAILED = "Could not lock the credential store to refresh the access token (another gr process may still be refreshing). Stored credentials were kept; retry the command.";
659
+ var REFRESH_REQUEST_FAILED = "Token refresh did not complete (network error or timeout). Stored credentials were kept; retry the command.";
660
+ var REFRESH_FAILED = "Token refresh failed. Stored credentials were kept; retry the command, and log in again if it keeps failing.";
661
+ var REFRESH_SUPERSEDED = "Token refresh was rejected, but the stored credentials changed meanwhile and were kept. Retry the command.";
662
+ var REFRESH_BAD_RESPONSE = "Token refresh returned an unusable response. Stored credentials were kept; retry the command.";
663
+ var REFRESH_SAVE_FAILED = "Token refresh succeeded, but the new credentials could not be saved. Retry the command, and log in again if it keeps failing.";
585
664
  function stderr(msg) {
586
665
  process.stderr.write(msg + "\n");
587
666
  }
@@ -604,7 +683,7 @@ function validateTokenResponse(tokenData) {
604
683
  if (tokenData.expires_in != null) {
605
684
  const raw = Number(tokenData.expires_in);
606
685
  if (!Number.isFinite(raw) || raw < 60 || raw > 31536e3) {
607
- stderr(` Warning: unusual expires_in value (${tokenData.expires_in}), using default`);
686
+ stderr(" Warning: unusual expires_in value in the token response, using default");
608
687
  } else {
609
688
  expiresIn = raw;
610
689
  }
@@ -825,7 +904,8 @@ async function performOAuthFlow(options) {
825
904
  method: "POST",
826
905
  headers: {
827
906
  ...auth.headers,
828
- "Content-Type": "application/x-www-form-urlencoded"
907
+ "Content-Type": "application/x-www-form-urlencoded",
908
+ "User-Agent": TOKEN_REQUEST_USER_AGENT
829
909
  },
830
910
  body: new URLSearchParams({
831
911
  grant_type: "authorization_code",
@@ -862,53 +942,150 @@ async function performOAuthFlow(options) {
862
942
  return { accessToken, credentials: creds };
863
943
  }
864
944
  }
865
- async function refreshAccessToken(envConfig, creds, credentialsManager, env, profile) {
866
- if (!creds.refreshToken || !creds.clientId) {
945
+ function parseOAuthErrorCode(body) {
946
+ try {
947
+ const parsed = JSON.parse(body);
948
+ if (parsed && typeof parsed === "object") {
949
+ const code = parsed.error;
950
+ if (typeof code === "string") return code;
951
+ }
952
+ } catch {
953
+ }
954
+ return void 0;
955
+ }
956
+ function isFreshEnough(creds) {
957
+ return !creds.expiresAt || new Date(creds.expiresAt).getTime() > Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS;
958
+ }
959
+ async function acquireRefreshLock(credentialsPath) {
960
+ try {
961
+ const cacheDir = dirname2(credentialsPath);
962
+ if (!existsSync3(cacheDir)) {
963
+ mkdirSync2(cacheDir, { recursive: true, mode: 448 });
964
+ }
965
+ return await lockfile.lock(credentialsPath, {
966
+ realpath: false,
967
+ retries: REFRESH_LOCK_RETRIES,
968
+ // The library default throws from a timer, which would crash the
969
+ // command. Losing the lock mid-refresh is worth a warning, not that.
970
+ // The library has already dropped its in-process record of the lock
971
+ // by the time this runs, so the `release()` in `refreshAccessToken`'s
972
+ // `finally` is refused (ENOTACQUIRED) and can never remove a lock
973
+ // another process has since taken.
974
+ onCompromised: () => {
975
+ stderr(
976
+ " Warning: lost the credential refresh lock; another gr process may refresh at the same time"
977
+ );
978
+ }
979
+ });
980
+ } catch {
981
+ throw new Error(REFRESH_LOCK_FAILED);
982
+ }
983
+ }
984
+ async function refreshAccessToken(envConfig, _creds, credentialsManager, env, profile) {
985
+ const profileName = credentialsManager.resolveProfileName(profile, env);
986
+ const release = await acquireRefreshLock(credentialsManager.getCredentialsFilePath(env));
987
+ try {
988
+ return await refreshUnderLock(envConfig, credentialsManager, env, profileName);
989
+ } finally {
990
+ await release().catch(() => void 0);
991
+ }
992
+ }
993
+ async function refreshUnderLock(envConfig, credentialsManager, env, profileName) {
994
+ const stored = credentialsManager.load(env, profileName);
995
+ if (stored?.accessToken && isFreshEnough(stored)) {
996
+ return stored;
997
+ }
998
+ if (!stored?.refreshToken || !stored.clientId) {
867
999
  stderr(" Cannot refresh: missing credentials (re-login required)");
868
1000
  return null;
869
1001
  }
870
- const profileName = credentialsManager.resolveProfileName(profile, env);
871
- const tokenUrl = `${envConfig.apiUrl}/api/v2/oauth/token`;
872
- const auth = tokenRequestAuth(creds.clientId, creds.clientSecret);
1002
+ const presentedRefreshToken = stored.refreshToken;
1003
+ const auth = tokenRequestAuth(stored.clientId, stored.clientSecret);
1004
+ let response;
873
1005
  try {
874
- const response = await fetch(tokenUrl, {
1006
+ response = await fetch(`${envConfig.apiUrl}/api/v2/oauth/token`, {
875
1007
  method: "POST",
876
1008
  headers: {
877
1009
  ...auth.headers,
878
- "Content-Type": "application/x-www-form-urlencoded"
1010
+ "Content-Type": "application/x-www-form-urlencoded",
1011
+ "User-Agent": TOKEN_REQUEST_USER_AGENT
879
1012
  },
880
1013
  body: new URLSearchParams({
881
1014
  grant_type: "refresh_token",
882
- refresh_token: creds.refreshToken,
1015
+ refresh_token: presentedRefreshToken,
883
1016
  ...auth.params
884
- })
1017
+ }),
1018
+ // Bounds the request and the body reads below. Never retried: a
1019
+ // second POST of the same token is itself a reuse.
1020
+ //
1021
+ // Known residual: an abort only stops *this* side. If the server
1022
+ // commits the rotation after the deadline, the response carrying the
1023
+ // successor is lost and the stored token is spent; the next refresh
1024
+ // of it is a genuine invalid_grant and clears the profile. The client
1025
+ // cannot recover an unseen successor; the deadline is long enough
1026
+ // that this needs a token endpoint stalled for 15 s, not a slow one.
1027
+ signal: AbortSignal.timeout(REFRESH_REQUEST_TIMEOUT_MS)
885
1028
  });
886
- if (!response.ok) {
887
- const body = await response.text();
888
- const isExpiredRefresh = isOAuthError(body, "invalid_grant");
889
- if (isExpiredRefresh) {
890
- stderr(" Refresh token expired, re-login required");
891
- } else {
892
- stderr(` Token refresh failed (${response.status}), clearing credentials`);
1029
+ } catch {
1030
+ throw new Error(REFRESH_REQUEST_FAILED);
1031
+ }
1032
+ if (!response.ok) {
1033
+ let body;
1034
+ try {
1035
+ body = await response.text();
1036
+ } catch {
1037
+ throw new Error(REFRESH_REQUEST_FAILED);
1038
+ }
1039
+ if (response.status === 400 && parseOAuthErrorCode(body) === "invalid_grant") {
1040
+ const current = credentialsManager.load(env, profileName);
1041
+ if (!current) {
1042
+ stderr(" The server rejected the stored refresh token; re-login required");
1043
+ return null;
893
1044
  }
894
- credentialsManager.clear(env, profileName);
1045
+ if (current.refreshToken === presentedRefreshToken) {
1046
+ credentialsManager.clear(env, profileName);
1047
+ stderr(" The server rejected the stored refresh token; re-login required");
1048
+ return null;
1049
+ }
1050
+ throw new Error(REFRESH_SUPERSEDED);
1051
+ }
1052
+ if (parseOAuthErrorCode(body) === "invalid_client") {
1053
+ stderr(" The server no longer recognises this profile's OAuth client; re-login required");
895
1054
  return null;
896
1055
  }
897
- const tokenData = await response.json();
898
- const { accessToken, refreshToken, expiresIn } = validateTokenResponse(tokenData);
899
- const updated = {
900
- ...creds,
901
- accessToken,
902
- refreshToken: refreshToken ?? creds.refreshToken,
903
- expiresAt: new Date(Date.now() + expiresIn * 1e3).toISOString()
904
- };
905
- credentialsManager.save(env, updated, profileName);
906
- return updated;
907
- } catch (err) {
908
- stderr(` Token refresh error: ${err instanceof Error ? err.message : "unknown"}`);
909
- credentialsManager.clear(env, profileName);
1056
+ throw new Error(REFRESH_FAILED);
1057
+ }
1058
+ let tokens;
1059
+ try {
1060
+ tokens = validateTokenResponse(await response.json());
1061
+ } catch {
1062
+ throw new Error(REFRESH_BAD_RESPONSE);
1063
+ }
1064
+ const latest = credentialsManager.load(env, profileName);
1065
+ if (!latest) {
1066
+ stderr(" Cannot refresh: missing credentials (re-login required)");
910
1067
  return null;
911
1068
  }
1069
+ if (latest.clientId !== stored.clientId || latest.refreshToken !== presentedRefreshToken) {
1070
+ if (latest.accessToken && isFreshEnough(latest)) {
1071
+ return latest;
1072
+ }
1073
+ throw new Error(REFRESH_SUPERSEDED);
1074
+ }
1075
+ const updated = {
1076
+ ...latest,
1077
+ accessToken: tokens.accessToken,
1078
+ // A server may omit `refresh_token` when it does not rotate; the
1079
+ // presented token then stays valid.
1080
+ refreshToken: tokens.refreshToken ?? presentedRefreshToken,
1081
+ expiresAt: new Date(Date.now() + tokens.expiresIn * 1e3).toISOString()
1082
+ };
1083
+ try {
1084
+ credentialsManager.save(env, updated, profileName);
1085
+ } catch {
1086
+ throw new Error(REFRESH_SAVE_FAILED);
1087
+ }
1088
+ return updated;
912
1089
  }
913
1090
  async function resolveNonInteractiveAuth(options) {
914
1091
  const { env, envConfig, credentialsManager, profile } = options;
@@ -1443,6 +1620,7 @@ export {
1443
1620
  CALLBACK_PATH,
1444
1621
  REDIRECT_URI,
1445
1622
  WorktreeConfigError,
1623
+ isRecord,
1446
1624
  parsePublishedTarget,
1447
1625
  classifyCheckout,
1448
1626
  getEnvironmentConfig,
@@ -1470,4 +1648,4 @@ export {
1470
1648
  getTextDelta,
1471
1649
  getErrorMessage
1472
1650
  };
1473
- //# sourceMappingURL=chunk-FSY2BM4J.js.map
1651
+ //# sourceMappingURL=chunk-BQ3WBHSA.js.map