getgloss 0.8.3 → 0.8.4

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
@@ -103,6 +103,14 @@ The background server exits automatically after a short idle window with no
103
103
  pending reviews. `gloss doctor` reports unmanaged daemon processes, and
104
104
  `gloss stop --all` cleans them up.
105
105
 
106
+ You do not need to unlock `~/.gloss/server.json` after finishing a review.
107
+ That file is only the background daemon pointer, not a review lock. If a
108
+ command reports a permission error while cleaning it up, run `gloss doctor`,
109
+ then try `gloss stop --all` from a normal terminal. If macOS flags made the
110
+ file immutable, inspect with `ls -lOe ~/.gloss ~/.gloss/server.json` and clear
111
+ the flag with `chflags nouchg ~/.gloss/server.json`. For sandboxed agents, set
112
+ `GLOSS_STATE_DIR` to a writable directory.
113
+
106
114
  `gloss clear` deletes completed review artifacts older than 30 days from
107
115
  `~/.gloss/reviews` while always preserving pending reviews. Use
108
116
  `gloss clear --dry-run` to preview candidates, or `--older-than <days>` to
package/dist/cli/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
+ import { randomUUID as randomUUID3 } from "crypto";
5
+ import { constants } from "fs";
6
+ import { access, rm as rm4, writeFile as writeFile3 } from "fs/promises";
7
+ import path6 from "path";
4
8
  import { Command } from "commander";
5
9
  import openBrowser from "open";
6
10
 
@@ -14,6 +18,9 @@ function formatError(error) {
14
18
  function isFileNotFound(error) {
15
19
  return error instanceof Error && "code" in error && error.code === "ENOENT";
16
20
  }
21
+ function isPermissionError(error) {
22
+ return error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM");
23
+ }
17
24
 
18
25
  // src/shared/paths.ts
19
26
  import { mkdir } from "fs/promises";
@@ -23,7 +30,7 @@ import path from "path";
23
30
  // package.json
24
31
  var package_default = {
25
32
  name: "getgloss",
26
- version: "0.8.3",
33
+ version: "0.8.4",
27
34
  description: "Local browser-based diff review for coding-agent loops.",
28
35
  type: "module",
29
36
  packageManager: "pnpm@10.33.2",
@@ -592,11 +599,127 @@ function cleanupResult({
592
599
  };
593
600
  }
594
601
 
602
+ // src/shared/server-info.ts
603
+ import { randomUUID as randomUUID2 } from "crypto";
604
+ import { readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
605
+ import path3 from "path";
606
+
607
+ // src/shared/json.ts
608
+ import { randomUUID } from "crypto";
609
+ import { rename, rm as rm2, writeFile } from "fs/promises";
610
+ import path2 from "path";
611
+ function serializeJson(value) {
612
+ return `${JSON.stringify(value, null, 2)}
613
+ `;
614
+ }
615
+ async function writeJsonFile(filePath, value) {
616
+ await writeTextFile(filePath, serializeJson(value));
617
+ }
618
+ async function writeTextFile(filePath, value) {
619
+ const tempPath = path2.join(
620
+ path2.dirname(filePath),
621
+ `.${path2.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
622
+ );
623
+ try {
624
+ await writeFile(tempPath, value);
625
+ await rename(tempPath, filePath);
626
+ } catch (error) {
627
+ await rm2(tempPath, { force: true }).catch(() => void 0);
628
+ throw error;
629
+ }
630
+ }
631
+
632
+ // src/shared/server-info.ts
633
+ async function readServerInfo() {
634
+ let raw;
635
+ try {
636
+ raw = await readFile2(globalServerFile(), "utf8");
637
+ } catch (error) {
638
+ if (isFileNotFound(error)) {
639
+ return null;
640
+ }
641
+ if (isPermissionError(error)) {
642
+ throw new Error(serverInfoPermissionMessage("read", error), { cause: error });
643
+ }
644
+ throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
645
+ cause: error
646
+ });
647
+ }
648
+ try {
649
+ return parseJson(raw, isServerInfo, "server info");
650
+ } catch (error) {
651
+ throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
652
+ cause: error
653
+ });
654
+ }
655
+ }
656
+ async function writeServerInfo(info) {
657
+ try {
658
+ await ensureDir(globalStateDir());
659
+ } catch (error) {
660
+ if (isPermissionError(error)) {
661
+ throw new Error(serverInfoPermissionMessage("create", error), { cause: error });
662
+ }
663
+ throw error;
664
+ }
665
+ try {
666
+ await writeJsonFile(globalServerFile(), info);
667
+ } catch (error) {
668
+ if (!isPermissionError(error)) {
669
+ throw error;
670
+ }
671
+ await assertStateDirWritable();
672
+ try {
673
+ await writeFile2(globalServerFile(), serializeServerInfo(info));
674
+ } catch (directWriteError) {
675
+ throw new Error(serverInfoPermissionMessage("write", directWriteError), {
676
+ cause: directWriteError
677
+ });
678
+ }
679
+ }
680
+ }
681
+ async function removeServerInfoFile() {
682
+ try {
683
+ await rm3(globalServerFile(), { force: true });
684
+ return null;
685
+ } catch (error) {
686
+ return serverInfoPermissionMessage("remove", error);
687
+ }
688
+ }
689
+ function serverInfoPermissionMessage(action, error) {
690
+ const stateDir = globalStateDir();
691
+ const source = process.env.GLOSS_STATE_DIR ? `GLOSS_STATE_DIR=${stateDir}` : "GLOSS_STATE_DIR is not set; defaulting to ~/.gloss";
692
+ return [
693
+ `Could not ${action} Gloss server state at ${globalServerFile()}: ${formatError(error)}.`,
694
+ "`server.json` is not a review lock, so there is nothing to unlock after a review.",
695
+ `Check that ${stateDir} and ${globalServerFile()} are owned and writable by your user.`,
696
+ `On macOS, if the file is immutable, run \`chflags nouchg "${globalServerFile()}"\`.`,
697
+ `For sandboxed agents, set GLOSS_STATE_DIR to a writable directory. ${source}.`
698
+ ].join(" ");
699
+ }
700
+ function serializeServerInfo(info) {
701
+ return `${JSON.stringify(info, null, 2)}
702
+ `;
703
+ }
704
+ async function assertStateDirWritable() {
705
+ const probePath = path3.join(
706
+ globalStateDir(),
707
+ `.server.json.${process.pid}.${randomUUID2()}.probe`
708
+ );
709
+ try {
710
+ await writeFile2(probePath, "");
711
+ await rm3(probePath, { force: true });
712
+ } catch (error) {
713
+ await rm3(probePath, { force: true }).catch(() => void 0);
714
+ throw new Error(serverInfoPermissionMessage("write", error), { cause: error });
715
+ }
716
+ }
717
+
595
718
  // src/cli/git.ts
596
719
  import { execa } from "execa";
597
720
 
598
721
  // src/shared/language.ts
599
- import path2 from "path";
722
+ import path4 from "path";
600
723
  var languageByExtension = {
601
724
  cjs: "js",
602
725
  css: "css",
@@ -618,7 +741,7 @@ var languageByExtension = {
618
741
  yml: "yaml"
619
742
  };
620
743
  function languageForPath(filePath) {
621
- const ext = path2.extname(filePath).slice(1).toLowerCase();
744
+ const ext = path4.extname(filePath).slice(1).toLowerCase();
622
745
  if (!ext) {
623
746
  return null;
624
747
  }
@@ -984,66 +1107,11 @@ async function assertGitAvailable() {
984
1107
  // src/cli/lifecycle.ts
985
1108
  import { execFile, spawn } from "child_process";
986
1109
  import { closeSync, existsSync, openSync } from "fs";
987
- import { rm as rm3 } from "fs/promises";
988
1110
  import { userInfo } from "os";
989
1111
  import { fileURLToPath } from "url";
990
1112
  import { promisify } from "util";
991
1113
  import getPort from "get-port";
992
1114
 
993
- // src/shared/server-info.ts
994
- import { readFile as readFile2 } from "fs/promises";
995
-
996
- // src/shared/json.ts
997
- import { randomUUID } from "crypto";
998
- import { rename, rm as rm2, writeFile } from "fs/promises";
999
- import path3 from "path";
1000
- function serializeJson(value) {
1001
- return `${JSON.stringify(value, null, 2)}
1002
- `;
1003
- }
1004
- async function writeJsonFile(filePath, value) {
1005
- await writeTextFile(filePath, serializeJson(value));
1006
- }
1007
- async function writeTextFile(filePath, value) {
1008
- const tempPath = path3.join(
1009
- path3.dirname(filePath),
1010
- `.${path3.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
1011
- );
1012
- try {
1013
- await writeFile(tempPath, value);
1014
- await rename(tempPath, filePath);
1015
- } catch (error) {
1016
- await rm2(tempPath, { force: true }).catch(() => void 0);
1017
- throw error;
1018
- }
1019
- }
1020
-
1021
- // src/shared/server-info.ts
1022
- async function readServerInfo() {
1023
- let raw;
1024
- try {
1025
- raw = await readFile2(globalServerFile(), "utf8");
1026
- } catch (error) {
1027
- if (isFileNotFound(error)) {
1028
- return null;
1029
- }
1030
- throw new Error(`Could not read server info at ${globalServerFile()}: ${formatError(error)}`, {
1031
- cause: error
1032
- });
1033
- }
1034
- try {
1035
- return parseJson(raw, isServerInfo, "server info");
1036
- } catch (error) {
1037
- throw new Error(`Invalid server info at ${globalServerFile()}: ${formatError(error)}`, {
1038
- cause: error
1039
- });
1040
- }
1041
- }
1042
- async function writeServerInfo(info) {
1043
- await ensureDir(globalStateDir());
1044
- await writeJsonFile(globalServerFile(), info);
1045
- }
1046
-
1047
1115
  // src/cli/server-client.ts
1048
1116
  var ServerClient = class {
1049
1117
  constructor(baseUrl) {
@@ -1172,20 +1240,20 @@ var ServerClient = class {
1172
1240
  }
1173
1241
  }
1174
1242
  }
1175
- async get(path5, guard, label) {
1176
- const response = await fetch(`${this.baseUrl}${path5}`);
1243
+ async get(path7, guard, label) {
1244
+ const response = await fetch(`${this.baseUrl}${path7}`);
1177
1245
  return parseResponse(response, guard, label);
1178
1246
  }
1179
- async post(path5, body, guard, label) {
1180
- const response = await fetch(`${this.baseUrl}${path5}`, {
1247
+ async post(path7, body, guard, label) {
1248
+ const response = await fetch(`${this.baseUrl}${path7}`, {
1181
1249
  method: "POST",
1182
1250
  headers: { "content-type": "application/json" },
1183
1251
  body: JSON.stringify(body)
1184
1252
  });
1185
1253
  return parseResponse(response, guard, label);
1186
1254
  }
1187
- async delete(path5, guard, label) {
1188
- const response = await fetch(`${this.baseUrl}${path5}`, { method: "DELETE" });
1255
+ async delete(path7, guard, label) {
1256
+ const response = await fetch(`${this.baseUrl}${path7}`, { method: "DELETE" });
1189
1257
  return parseResponse(response, guard, label);
1190
1258
  }
1191
1259
  };
@@ -1276,7 +1344,12 @@ async function launchServer(port) {
1276
1344
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1277
1345
  stateDir: globalStateDir()
1278
1346
  };
1279
- await writeServerInfo(info);
1347
+ try {
1348
+ await writeServerInfo(info);
1349
+ } catch (error) {
1350
+ await terminatePid(info.pid);
1351
+ throw error;
1352
+ }
1280
1353
  const deadline = Date.now() + 8e3;
1281
1354
  while (Date.now() < deadline) {
1282
1355
  if (await isServerResponsive(info)) {
@@ -1290,7 +1363,7 @@ async function launchServer(port) {
1290
1363
  }
1291
1364
  async function stopServer(options = {}) {
1292
1365
  if (options.all) {
1293
- const info2 = await readServerInfo();
1366
+ const { info: info2, warning: readWarning2 } = await readServerInfoForStop();
1294
1367
  const daemonPids = await listGlossDaemonPids();
1295
1368
  const stoppedPids = [];
1296
1369
  for (const pid of daemonPids) {
@@ -1298,26 +1371,27 @@ async function stopServer(options = {}) {
1298
1371
  stoppedPids.push(pid);
1299
1372
  }
1300
1373
  }
1301
- await rm3(globalServerFile(), { force: true });
1302
- return { stopped: stoppedPids.length > 0, info: info2, stoppedPids };
1374
+ return withWarning(
1375
+ { stopped: stoppedPids.length > 0, info: info2, stoppedPids },
1376
+ combineWarnings(readWarning2, await removeServerInfoFile())
1377
+ );
1303
1378
  }
1304
- const info = await readServerInfo();
1379
+ const { info, warning: readWarning } = await readServerInfoForStop();
1305
1380
  if (!info) {
1306
- return { stopped: false, info: null };
1381
+ return withWarning({ stopped: false, info: null }, readWarning);
1307
1382
  }
1308
1383
  if (!isPidAlive(info.pid)) {
1309
- await removeServerInfoForPid(info.pid);
1310
- return { stopped: false, info };
1384
+ return withWarning({ stopped: false, info }, await removeServerInfoForPid(info.pid));
1311
1385
  }
1312
1386
  if (!await isGlossDaemonPid(info.pid)) {
1313
- await removeServerInfoForPid(info.pid);
1314
- return { stopped: false, info };
1387
+ return withWarning({ stopped: false, info }, await removeServerInfoForPid(info.pid));
1315
1388
  }
1316
1389
  const stopped = await terminatePid(info.pid);
1390
+ let warning = null;
1317
1391
  if (stopped) {
1318
- await removeServerInfoForPid(info.pid);
1392
+ warning = await removeServerInfoForPid(info.pid);
1319
1393
  }
1320
- return { stopped, info };
1394
+ return withWarning({ stopped, info }, warning);
1321
1395
  }
1322
1396
  function isPidAlive(pid) {
1323
1397
  if (pid <= 0) {
@@ -1368,8 +1442,23 @@ async function waitForPidExit(pid, timeoutMs) {
1368
1442
  async function removeServerInfoForPid(pid) {
1369
1443
  const current = await readServerInfo().catch(() => null);
1370
1444
  if (!current || current.pid === pid) {
1371
- await rm3(globalServerFile(), { force: true });
1445
+ return removeServerInfoFile();
1372
1446
  }
1447
+ return null;
1448
+ }
1449
+ function withWarning(result, warning) {
1450
+ return warning ? { ...result, warning } : result;
1451
+ }
1452
+ async function readServerInfoForStop() {
1453
+ try {
1454
+ return { info: await readServerInfo(), warning: null };
1455
+ } catch (error) {
1456
+ return { info: null, warning: error instanceof Error ? error.message : String(error) };
1457
+ }
1458
+ }
1459
+ function combineWarnings(...warnings) {
1460
+ const present = warnings.filter((warning) => Boolean(warning));
1461
+ return present.length > 0 ? present.join(" ") : null;
1373
1462
  }
1374
1463
  async function isGlossDaemonPid(pid) {
1375
1464
  const command = await readProcessCommand(pid);
@@ -1409,7 +1498,7 @@ function isGlossDaemonCommand(command) {
1409
1498
  // src/server/store.ts
1410
1499
  import { createHash } from "crypto";
1411
1500
  import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
1412
- import path4 from "path";
1501
+ import path5 from "path";
1413
1502
  import { ulid } from "ulid";
1414
1503
 
1415
1504
  // src/shared/comments.ts
@@ -2182,9 +2271,9 @@ function reconcileTurn(meta, diff, feedback, resolution) {
2182
2271
  status,
2183
2272
  submittedAt: feedback?.timestamp ?? meta.submittedAt,
2184
2273
  resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
2185
- feedbackPath: feedback ? meta.feedbackPath ?? path4.join(meta.artifactDir, "feedback.json") : void 0,
2186
- markdownPath: feedback ? meta.markdownPath ?? path4.join(meta.artifactDir, "feedback.md") : void 0,
2187
- resolvedPath: resolution ? meta.resolvedPath ?? path4.join(meta.artifactDir, "resolved.json") : void 0,
2274
+ feedbackPath: feedback ? meta.feedbackPath ?? path5.join(meta.artifactDir, "feedback.json") : void 0,
2275
+ markdownPath: feedback ? meta.markdownPath ?? path5.join(meta.artifactDir, "feedback.md") : void 0,
2276
+ resolvedPath: resolution ? meta.resolvedPath ?? path5.join(meta.artifactDir, "resolved.json") : void 0,
2188
2277
  diff,
2189
2278
  ...feedback ? { feedback } : {},
2190
2279
  ...resolution ? { resolution } : {}
@@ -2378,9 +2467,7 @@ program.command("status").description("Show server and active reviews").action(a
2378
2467
  program.command("stop").description("Stop the managed background server").option("--all", "stop all Gloss daemon processes for the current user").action(async (options) => {
2379
2468
  const globals = program.opts();
2380
2469
  const result = await stopServer({ all: options.all });
2381
- globals.json ? printJson(result) : printPlain(
2382
- options.all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running"
2383
- );
2470
+ globals.json ? printJson(result) : printPlain(formatStopResult(result, options.all === true));
2384
2471
  });
2385
2472
  program.command("clear").description("Delete old completed review artifacts").option(
2386
2473
  "--older-than <days>",
@@ -2438,11 +2525,19 @@ program.command("doctor").description("Diagnose setup and validate git/state").a
2438
2525
  detail: error instanceof Error ? error.message : String(error)
2439
2526
  });
2440
2527
  }
2441
- const info = await readServerInfo();
2528
+ checks.push(await checkStateDirAccess());
2529
+ checks.push(await checkServerInfoAccess());
2530
+ let info = null;
2531
+ let serverStateError = null;
2532
+ try {
2533
+ info = await readServerInfo();
2534
+ } catch (error) {
2535
+ serverStateError = error;
2536
+ }
2442
2537
  checks.push({
2443
2538
  name: "server",
2444
2539
  ok: info ? await isServerResponsive(info) : false,
2445
- detail: info ? serverUrl(info) : "not started"
2540
+ detail: info ? serverUrl(info) : serverStateError ? formatError(serverStateError) : "not started"
2446
2541
  });
2447
2542
  try {
2448
2543
  const daemonPids = await listGlossDaemonPids();
@@ -2496,6 +2591,46 @@ async function watchReviewWithReconnect(reviewId, initialInfo, timeoutSeconds, o
2496
2591
  }
2497
2592
  }
2498
2593
  }
2594
+ function formatStopResult(result, all) {
2595
+ const status = all && result.stoppedPids ? `Stopped ${result.stoppedPids.length} Gloss daemon(s)` : result.stopped ? "Gloss server stopped" : "Gloss server was not running";
2596
+ return result.warning ? `${status}
2597
+ Warning: ${result.warning}` : status;
2598
+ }
2599
+ async function checkStateDirAccess() {
2600
+ const probePath = path6.join(globalStateDir(), `.doctor-${process.pid}-${randomUUID3()}.tmp`);
2601
+ try {
2602
+ await ensureDir(globalStateDir());
2603
+ await access(globalStateDir(), constants.R_OK | constants.W_OK | constants.X_OK);
2604
+ await writeFile3(probePath, "");
2605
+ await rm4(probePath, { force: true });
2606
+ return { name: "state-dir", ok: true, detail: stateDirDetail() };
2607
+ } catch (error) {
2608
+ await rm4(probePath, { force: true }).catch(() => void 0);
2609
+ return {
2610
+ name: "state-dir",
2611
+ ok: false,
2612
+ detail: `${stateDirDetail()}: ${formatError(error)}. Set GLOSS_STATE_DIR to a writable directory for sandboxed agents.`
2613
+ };
2614
+ }
2615
+ }
2616
+ async function checkServerInfoAccess() {
2617
+ try {
2618
+ await access(globalServerFile(), constants.R_OK | constants.W_OK);
2619
+ return { name: "server-json", ok: true, detail: globalServerFile() };
2620
+ } catch (error) {
2621
+ if (isFileNotFound(error)) {
2622
+ return { name: "server-json", ok: true, detail: "not present" };
2623
+ }
2624
+ return {
2625
+ name: "server-json",
2626
+ ok: false,
2627
+ detail: serverInfoPermissionMessage("access", error)
2628
+ };
2629
+ }
2630
+ }
2631
+ function stateDirDetail() {
2632
+ return process.env.GLOSS_STATE_DIR ? `${globalStateDir()} (from GLOSS_STATE_DIR)` : `${globalStateDir()} (default; set GLOSS_STATE_DIR for a writable sandbox state dir)`;
2633
+ }
2499
2634
  async function baseForExistingReview(client, reviewId) {
2500
2635
  const record = await client.getReview(reviewId);
2501
2636
  return record.diff.scope.mode === "explicit" ? record.diff.scope.requestedBase ?? record.diff.base.ref : null;