@go-labs-sg/gosh 0.4.2 → 0.5.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.
Files changed (2) hide show
  1. package/dist/index.js +234 -32
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -375,6 +375,20 @@ import_dotenv.config({ path: path.resolve(process.cwd(), ".env"), quiet: true })
375
375
  // src/cli.ts
376
376
  import { readFileSync } from "fs";
377
377
 
378
+ // ../../cli-auth/src/client/credential-store.ts
379
+ import { createHash as createHash2, randomUUID } from "crypto";
380
+ import {
381
+ chmod,
382
+ lstat,
383
+ mkdir,
384
+ open as open2,
385
+ readFile,
386
+ stat,
387
+ unlink
388
+ } from "fs/promises";
389
+ import { userInfo } from "os";
390
+ import { join } from "path";
391
+
378
392
  // ../../cli-auth/src/protocol/constants.ts
379
393
  var CLI_ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
380
394
  var CLI_DEVICE_AUTHORIZATION_TTL_MS = 10 * 60 * 1000;
@@ -19028,13 +19042,15 @@ var deviceTokenErrorSchema = exports_external.object({
19028
19042
  "authorization_pending",
19029
19043
  "slow_down",
19030
19044
  "access_denied",
19031
- "expired_token"
19045
+ "expired_token",
19046
+ "invalid_grant"
19032
19047
  ])
19033
19048
  }).strict();
19034
19049
  var refreshRequestSchema = exports_external.object({ refreshToken: exports_external.string().min(40) }).strict();
19035
19050
  var storedCliCredentialsSchema = exports_external.object({
19036
19051
  accessToken: exports_external.string().min(1),
19037
19052
  accessTokenExpiresAt: dateTimeString,
19053
+ issuer: exports_external.string().url().optional(),
19038
19054
  refreshToken: exports_external.string().min(1),
19039
19055
  refreshTokenExpiresAt: dateTimeString,
19040
19056
  session: cliSessionIdentitySchema
@@ -19086,6 +19102,77 @@ var defaultSecrets = () => {
19086
19102
  assertBunRuntime();
19087
19103
  return Bun.secrets;
19088
19104
  };
19105
+ var refreshLockTimeoutMs = 5 * 60 * 1000;
19106
+ var refreshLockRetryMs = 50;
19107
+ var getCredentialLockDirectoryPath = () => {
19108
+ return join(userInfo().homedir, ".cache", "go-labs-cli", "locks");
19109
+ };
19110
+ var ensurePrivateCredentialLockDirectory = async () => {
19111
+ const directory = getCredentialLockDirectoryPath();
19112
+ await mkdir(directory, { mode: 448, recursive: true });
19113
+ const inspectDirectory = async () => {
19114
+ const directoryStat2 = await lstat(directory);
19115
+ if (directoryStat2.isSymbolicLink() || !directoryStat2.isDirectory()) {
19116
+ throw new Error("The CLI credential lock path is not a private directory.");
19117
+ }
19118
+ const uid = process.getuid?.();
19119
+ if (uid !== undefined && directoryStat2.uid !== uid) {
19120
+ throw new Error("The CLI credential lock directory belongs to another user.");
19121
+ }
19122
+ return directoryStat2;
19123
+ };
19124
+ const directoryStat = await inspectDirectory();
19125
+ if ((directoryStat.mode & 63) !== 0) {
19126
+ await chmod(directory, 448);
19127
+ const securedDirectoryStat = await inspectDirectory();
19128
+ if ((securedDirectoryStat.mode & 63) !== 0) {
19129
+ throw new Error("Unable to secure the CLI credential lock directory.");
19130
+ }
19131
+ }
19132
+ return directory;
19133
+ };
19134
+ var getCredentialLockPath = async (product, account) => {
19135
+ const key = createHash2("sha256").update(`${product}:${account}`).digest("hex");
19136
+ return join(await ensurePrivateCredentialLockDirectory(), `${key}.lock`);
19137
+ };
19138
+ var acquireRefreshLock = async (path2) => {
19139
+ const deadline = Date.now() + refreshLockTimeoutMs;
19140
+ while (Date.now() < deadline) {
19141
+ try {
19142
+ const handle = await open2(path2, "wx");
19143
+ const owner = randomUUID();
19144
+ try {
19145
+ await handle.writeFile(owner);
19146
+ } catch (cause) {
19147
+ await handle.close().catch(() => {
19148
+ return;
19149
+ });
19150
+ await unlink(path2).catch(() => {
19151
+ return;
19152
+ });
19153
+ throw cause;
19154
+ }
19155
+ return { handle, owner };
19156
+ } catch (cause) {
19157
+ if (!(cause instanceof Error) || !("code" in cause) || cause.code !== "EEXIST") {
19158
+ throw cause;
19159
+ }
19160
+ try {
19161
+ const lock = await stat(path2);
19162
+ if (Date.now() - lock.mtimeMs > refreshLockTimeoutMs) {
19163
+ throw new Error(`The CLI credential lock appears stale at ${path2}. Ensure no CLI authentication command is running, remove that lock file, and retry.`);
19164
+ }
19165
+ } catch (statCause) {
19166
+ if (statCause instanceof Error && "code" in statCause && statCause.code === "ENOENT") {
19167
+ continue;
19168
+ }
19169
+ throw statCause;
19170
+ }
19171
+ await Bun.sleep(refreshLockRetryMs);
19172
+ }
19173
+ }
19174
+ throw new Error("Timed out waiting for another CLI process to refresh credentials. Retry the command.");
19175
+ };
19089
19176
  var createBunCredentialStore = ({
19090
19177
  account = "default",
19091
19178
  product,
@@ -19113,16 +19200,46 @@ var createBunCredentialStore = ({
19113
19200
  service,
19114
19201
  value: JSON.stringify(parsed)
19115
19202
  }));
19203
+ },
19204
+ withCredentialLock: async (operation) => {
19205
+ const refreshLockPath = await getCredentialLockPath(product, account);
19206
+ const lock = await acquireRefreshLock(refreshLockPath);
19207
+ try {
19208
+ return await operation();
19209
+ } finally {
19210
+ await lock.handle.close();
19211
+ const owner = await readFile(refreshLockPath, "utf8").catch(() => null);
19212
+ if (owner === lock.owner) {
19213
+ await unlink(refreshLockPath).catch(() => {
19214
+ return;
19215
+ });
19216
+ }
19217
+ }
19116
19218
  }
19117
19219
  };
19118
19220
  };
19119
19221
  // ../../cli-auth/src/client/login.ts
19120
19222
  import { hostname as hostname3 } from "os";
19121
- var normalizeBaseUrl = (value) => value.replace(/\/+$/, "");
19122
- var postJson = async (fetchImplementation, url2, body) => fetchImplementation(url2, {
19223
+ var normalizeCliAuthIssuer = (value) => {
19224
+ const url2 = new URL(value);
19225
+ url2.hash = "";
19226
+ url2.search = "";
19227
+ url2.pathname = url2.pathname.replace(/\/+$/, "");
19228
+ return url2.toString().replace(/\/+$/, "");
19229
+ };
19230
+ var isCliCredentialForIssuer = (credentials, apiBaseUrl) => credentials.issuer === normalizeCliAuthIssuer(apiBaseUrl);
19231
+ var credentialIssuerError = (issuer) => issuer ? new Error("These CLI credentials belong to a different API URL. Run auth login for this API URL before retrying.") : new Error("These CLI credentials were created by an older CLI and are not bound to an API URL. Run auth login again.");
19232
+ var assertCredentialIssuer = (credentials, apiBaseUrl) => {
19233
+ const expectedIssuer = normalizeCliAuthIssuer(apiBaseUrl);
19234
+ if (!credentials.issuer || credentials.issuer !== expectedIssuer) {
19235
+ throw credentialIssuerError(credentials.issuer);
19236
+ }
19237
+ };
19238
+ var postJson = async (fetchImplementation, url2, body, options = {}) => fetchImplementation(url2, {
19123
19239
  body: JSON.stringify(body),
19124
19240
  headers: { "content-type": "application/json" },
19125
- method: "POST"
19241
+ method: "POST",
19242
+ ...options
19126
19243
  });
19127
19244
  var readJson = async (response) => {
19128
19245
  try {
@@ -19131,9 +19248,29 @@ var readJson = async (response) => {
19131
19248
  throw new Error(`CLI authentication returned HTTP ${response.status} without valid JSON.`);
19132
19249
  }
19133
19250
  };
19134
- var toStoredCredentials = (response, now) => ({
19251
+ var localCredentialLocks = new WeakMap;
19252
+ var withCredentialLock = (store, operation) => {
19253
+ if (store.withCredentialLock) {
19254
+ return store.withCredentialLock(operation);
19255
+ }
19256
+ const previous = localCredentialLocks.get(store) ?? Promise.resolve();
19257
+ const pending = previous.then(operation, operation);
19258
+ const tail = pending.then(() => {
19259
+ return;
19260
+ }, () => {
19261
+ return;
19262
+ });
19263
+ localCredentialLocks.set(store, tail);
19264
+ return pending.finally(() => {
19265
+ if (localCredentialLocks.get(store) === tail) {
19266
+ localCredentialLocks.delete(store);
19267
+ }
19268
+ });
19269
+ };
19270
+ var toStoredCredentials = (response, issuer, now) => ({
19135
19271
  accessToken: response.accessToken,
19136
19272
  accessTokenExpiresAt: new Date(now.getTime() + response.expiresIn * 1000).toISOString(),
19273
+ issuer,
19137
19274
  refreshToken: response.refreshToken,
19138
19275
  refreshTokenExpiresAt: new Date(now.getTime() + response.refreshExpiresIn * 1000).toISOString(),
19139
19276
  session: response.session
@@ -19167,7 +19304,7 @@ var loginWithBrowser = async ({
19167
19304
  sleep = (milliseconds) => Bun.sleep(milliseconds),
19168
19305
  store
19169
19306
  }) => {
19170
- const baseUrl = normalizeBaseUrl(apiBaseUrl);
19307
+ const baseUrl = normalizeCliAuthIssuer(apiBaseUrl);
19171
19308
  const pkce = createPkcePair();
19172
19309
  const beginResponse = await postJson(fetchImplementation, `${baseUrl}/api/cli-auth/device`, {
19173
19310
  clientName,
@@ -19194,8 +19331,8 @@ var loginWithBrowser = async ({
19194
19331
  const payload = await readJson(response);
19195
19332
  if (response.ok) {
19196
19333
  const issued = tokenResponseSchema.parse(payload);
19197
- const credentials = toStoredCredentials(issued, now());
19198
- await store.set(credentials);
19334
+ const credentials = toStoredCredentials(issued, baseUrl, now());
19335
+ await withCredentialLock(store, () => store.set(credentials));
19199
19336
  return credentials;
19200
19337
  }
19201
19338
  const error61 = deviceTokenErrorSchema.safeParse(payload);
@@ -19216,6 +19353,26 @@ var loginWithBrowser = async ({
19216
19353
  throw new Error("CLI authentication expired. Run auth login again.");
19217
19354
  };
19218
19355
  var refreshes = new WeakMap;
19356
+ var refreshRequestTimeoutMs = 30000;
19357
+ var isInvalidGrantResponse = (payload) => {
19358
+ const parsed = deviceTokenErrorSchema.safeParse(payload);
19359
+ return parsed.success && parsed.data.error === "invalid_grant";
19360
+ };
19361
+ var readJsonOrNull = async (response) => {
19362
+ try {
19363
+ return await response.json();
19364
+ } catch {
19365
+ return null;
19366
+ }
19367
+ };
19368
+ var deleteInvalidCredentials = async (store, credentials) => {
19369
+ const current = await store.get();
19370
+ if (!current)
19371
+ return;
19372
+ if (current.issuer === credentials.issuer && current.refreshToken === credentials.refreshToken) {
19373
+ await store.delete();
19374
+ }
19375
+ };
19219
19376
  var refreshAccessToken = async ({
19220
19377
  apiBaseUrl,
19221
19378
  credentials,
@@ -19223,16 +19380,56 @@ var refreshAccessToken = async ({
19223
19380
  now,
19224
19381
  store
19225
19382
  }) => {
19226
- const response = await postJson(fetchImplementation, `${normalizeBaseUrl(apiBaseUrl)}/api/cli-auth/refresh`, { refreshToken: credentials.refreshToken });
19383
+ assertCredentialIssuer(credentials, apiBaseUrl);
19384
+ let response;
19385
+ try {
19386
+ response = await postJson(fetchImplementation, `${normalizeCliAuthIssuer(apiBaseUrl)}/api/cli-auth/refresh`, { refreshToken: credentials.refreshToken }, { signal: AbortSignal.timeout(refreshRequestTimeoutMs) });
19387
+ } catch {
19388
+ throw new Error("Unable to refresh the CLI session because the API could not be reached. Credentials were kept; retry the command.");
19389
+ }
19227
19390
  if (!response.ok) {
19228
- await store.delete();
19229
- throw new Error("The CLI session expired or was revoked. Run auth login again.");
19391
+ const payload2 = await readJsonOrNull(response);
19392
+ if (isInvalidGrantResponse(payload2)) {
19393
+ await deleteInvalidCredentials(store, credentials);
19394
+ throw new Error("The CLI session expired or was revoked. Run auth login again.");
19395
+ }
19396
+ throw new Error(`Unable to refresh the CLI session (HTTP ${response.status}). Credentials were kept; retry the command.`);
19397
+ }
19398
+ const payload = await readJsonOrNull(response);
19399
+ if (payload === null) {
19400
+ throw new Error("Unable to refresh the CLI session because the API returned malformed JSON. Credentials were kept; retry the command.");
19401
+ }
19402
+ let issued;
19403
+ try {
19404
+ issued = tokenResponseSchema.parse(payload);
19405
+ } catch {
19406
+ throw new Error("Unable to refresh the CLI session because the API returned invalid credentials. Credentials were kept; retry the command.");
19230
19407
  }
19231
- const issued = tokenResponseSchema.parse(await readJson(response));
19232
- const stored = toStoredCredentials(issued, now());
19408
+ const stored = toStoredCredentials(issued, normalizeCliAuthIssuer(apiBaseUrl), now());
19233
19409
  await store.set(stored);
19234
19410
  return stored.accessToken;
19235
19411
  };
19412
+ var refreshWithCurrentCredentials = async ({
19413
+ apiBaseUrl,
19414
+ fetch: fetchImplementation,
19415
+ now,
19416
+ store
19417
+ }) => {
19418
+ const credentials = await store.get();
19419
+ if (!credentials)
19420
+ throw new Error("Not authenticated. Run auth login first.");
19421
+ assertCredentialIssuer(credentials, apiBaseUrl);
19422
+ if (Date.parse(credentials.accessTokenExpiresAt) - now().getTime() > 60000) {
19423
+ return credentials.accessToken;
19424
+ }
19425
+ return refreshAccessToken({
19426
+ apiBaseUrl,
19427
+ credentials,
19428
+ fetch: fetchImplementation,
19429
+ now,
19430
+ store
19431
+ });
19432
+ };
19236
19433
  var getAuthenticatedAccessToken = async ({
19237
19434
  apiBaseUrl,
19238
19435
  fetch: fetchImplementation = globalThis.fetch,
@@ -19242,19 +19439,20 @@ var getAuthenticatedAccessToken = async ({
19242
19439
  const credentials = await store.get();
19243
19440
  if (!credentials)
19244
19441
  throw new Error("Not authenticated. Run auth login first.");
19442
+ assertCredentialIssuer(credentials, apiBaseUrl);
19245
19443
  if (Date.parse(credentials.accessTokenExpiresAt) - now().getTime() > 60000) {
19246
19444
  return credentials.accessToken;
19247
19445
  }
19248
19446
  const existing = refreshes.get(store);
19249
19447
  if (existing)
19250
19448
  return existing;
19251
- const refresh = refreshAccessToken({
19449
+ const refreshOperation = () => withCredentialLock(store, () => refreshWithCurrentCredentials({
19252
19450
  apiBaseUrl,
19253
- credentials,
19254
19451
  fetch: fetchImplementation,
19255
19452
  now,
19256
19453
  store
19257
- }).finally(() => refreshes.delete(store));
19454
+ }));
19455
+ const refresh = refreshOperation().finally(() => refreshes.delete(store));
19258
19456
  refreshes.set(store, refresh);
19259
19457
  return refresh;
19260
19458
  };
@@ -19263,18 +19461,22 @@ var logoutCliSession = async ({
19263
19461
  fetch: fetchImplementation = globalThis.fetch,
19264
19462
  store
19265
19463
  }) => {
19266
- const credentials = await store.get();
19267
- let remoteRevoked = credentials === null;
19268
- if (credentials) {
19269
- try {
19270
- const response = await postJson(fetchImplementation, `${normalizeBaseUrl(apiBaseUrl)}/api/cli-auth/revoke`, { refreshToken: credentials.refreshToken });
19271
- remoteRevoked = response.ok;
19272
- } catch {
19273
- remoteRevoked = false;
19464
+ const issuer = normalizeCliAuthIssuer(apiBaseUrl);
19465
+ return withCredentialLock(store, async () => {
19466
+ const credentials = await store.get();
19467
+ let remoteRevoked = credentials === null;
19468
+ if (credentials) {
19469
+ try {
19470
+ assertCredentialIssuer(credentials, apiBaseUrl);
19471
+ const response = await postJson(fetchImplementation, `${issuer}/api/cli-auth/revoke`, { refreshToken: credentials.refreshToken }, { signal: AbortSignal.timeout(refreshRequestTimeoutMs) });
19472
+ remoteRevoked = response.ok;
19473
+ } catch {
19474
+ remoteRevoked = false;
19475
+ }
19274
19476
  }
19275
- }
19276
- await store.delete();
19277
- return { remoteRevoked };
19477
+ await store.delete();
19478
+ return { remoteRevoked };
19479
+ });
19278
19480
  };
19279
19481
  // src/api-client.ts
19280
19482
  import {
@@ -20491,7 +20693,7 @@ var findCommand = (positional) => {
20491
20693
  return;
20492
20694
  };
20493
20695
  // src/features/upload.ts
20494
- import { open as open2, readFile, stat } from "fs/promises";
20696
+ import { open as open3, readFile as readFile2, stat as stat2 } from "fs/promises";
20495
20697
  import { basename, extname } from "path";
20496
20698
  var contentTypes = {
20497
20699
  ".gif": "image/gif",
@@ -20561,7 +20763,7 @@ var uploadMultipartMissionMedia = async ({
20561
20763
  input: input2,
20562
20764
  metadata
20563
20765
  }) => {
20564
- const file2 = await open2(filePath, "r");
20766
+ const file2 = await open3(filePath, "r");
20565
20767
  const completedParts = [];
20566
20768
  try {
20567
20769
  for (const part of metadata.parts) {
@@ -20607,7 +20809,7 @@ var uploadFileForCommand = async ({
20607
20809
  if (!isRecord2(input2)) {
20608
20810
  throw new CliError("VALIDATION", "Upload command input must be an object.");
20609
20811
  }
20610
- const file2 = await stat(filePath).catch(() => null);
20812
+ const file2 = await stat2(filePath).catch(() => null);
20611
20813
  if (!file2?.isFile()) {
20612
20814
  throw new CliError("VALIDATION", `File not found: ${filePath}`);
20613
20815
  }
@@ -20634,7 +20836,7 @@ var uploadFileForCommand = async ({
20634
20836
  return { completed, key: multipart.key, strategy: multipart.strategy };
20635
20837
  }
20636
20838
  const single = asSingleResult(requested);
20637
- const singleFile = await readFile(filePath);
20839
+ const singleFile = await readFile2(filePath);
20638
20840
  await putPart(single.uploadUrl, singleFile.buffer.slice(singleFile.byteOffset, singleFile.byteOffset + singleFile.byteLength), contentType);
20639
20841
  return {
20640
20842
  ...single.imageUrl ? { imageUrl: single.imageUrl } : {},
@@ -20842,7 +21044,7 @@ Waiting for approval\u2026
20842
21044
  }
20843
21045
  if (first === "auth" && rest[0] === "status" && rest.length === 1) {
20844
21046
  const credentials = await credentialStore.get();
20845
- return emitSuccess(dependencies.runtime, { apiBaseUrl, cliVersion: GOSH_CLI_VERSION, command: "auth status" }, credentials ? {
21047
+ return emitSuccess(dependencies.runtime, { apiBaseUrl, cliVersion: GOSH_CLI_VERSION, command: "auth status" }, credentials && isCliCredentialForIssuer(credentials, apiBaseUrl) ? {
20846
21048
  authenticated: true,
20847
21049
  accessTokenExpiresAt: credentials.accessTokenExpiresAt,
20848
21050
  refreshTokenExpiresAt: credentials.refreshTokenExpiresAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-labs-sg/gosh",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "GOSH CLI for organizers and AI agents — manage organizations, events, teams, stations, missions, templates, and live operations through the GOSH API.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",