@saadnvd1/agent-os 0.3.826 → 0.3.828

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.
@@ -1937,6 +1937,14 @@
1937
1937
  "area": "Chat",
1938
1938
  "text": "Cancelled questions from GitHub Copilot chats now clear right away instead of staying on screen.",
1939
1939
  "url": "https://github.com/saadnvd1/agent-os/pull/552"
1940
+ },
1941
+ {
1942
+ "id": 555,
1943
+ "date": "2026-11-02",
1944
+ "kind": "fixed",
1945
+ "area": "Updates",
1946
+ "text": "Automatic updates no longer get stuck after the first one because of a file the build rewrites.",
1947
+ "url": "https://github.com/saadnvd1/agent-os/pull/555"
1940
1948
  }
1941
1949
  ]
1942
1950
  }
@@ -0,0 +1,29 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ /** Files the build rewrites in a checkout; one list for the updater and the deploy scripts. */
5
+ export function generatedFiles(root: string): string[] {
6
+ try {
7
+ return fs
8
+ .readFileSync(path.join(root, "scripts/generated-files.txt"), "utf8")
9
+ .split("\n")
10
+ .map((l) => l.trim())
11
+ .filter((l) => l && !l.startsWith("#"));
12
+ } catch {
13
+ return [];
14
+ }
15
+ }
16
+
17
+ /** Splits `git status --porcelain` paths into generated files and real local changes. */
18
+ export function splitDirty(
19
+ porcelain: string,
20
+ generated: string[]
21
+ ): { generated: string[]; blocking: string[] } {
22
+ const out = { generated: [] as string[], blocking: [] as string[] };
23
+ for (const line of porcelain.split("\n")) {
24
+ const file = line.slice(3).trim();
25
+ if (!file) continue;
26
+ (generated.includes(file) ? out.generated : out.blocking).push(file);
27
+ }
28
+ return out;
29
+ }
@@ -32,6 +32,7 @@ import {
32
32
  type CheckDeps,
33
33
  type CheckResult,
34
34
  } from "./check";
35
+ import { generatedFiles, splitDirty } from "./generated";
35
36
  import { decideAutoUpdate, pickTarget, type AutoDecision } from "./auto";
36
37
  import {
37
38
  attemptOf,
@@ -325,6 +326,16 @@ export type Refusal = { code: UpdateRefusal; error: string };
325
326
  export const NO_RESTART =
326
327
  "This server can't restart itself, so an update would leave the old build running on new files. Set AGENTOS_RESTART to its restart command, or start it with agent-os start (started at login on Linux: run agent-os enable again to update its service).";
327
328
 
329
+ /** A build leaves these modified; the pull would refuse to overwrite them. */
330
+ export async function discardGenerated(deps: RunDeps): Promise<void> {
331
+ const { generated } = splitDirty(
332
+ await deps.git(["status", "--porcelain", "--untracked-files=no"]),
333
+ generatedFiles(deps.cwd)
334
+ );
335
+ if (generated.length)
336
+ await deps.git(["checkout", "--", ...generated]).catch(() => {});
337
+ }
338
+
328
339
  export async function preflight(
329
340
  check: CheckResult | null,
330
341
  deps: RunDeps
@@ -356,12 +367,10 @@ export async function preflight(
356
367
  error:
357
368
  "Another update or deploy is building. Try again when it finishes.",
358
369
  };
359
- const dirty = (
360
- await deps.git(["status", "--porcelain", "--untracked-files=no"])
361
- )
362
- .split("\n")
363
- .map((l) => l.slice(3).trim())
364
- .filter(Boolean);
370
+ const dirty = splitDirty(
371
+ await deps.git(["status", "--porcelain", "--untracked-files=no"]),
372
+ generatedFiles(deps.cwd)
373
+ ).blocking;
365
374
  if (dirty.length)
366
375
  return {
367
376
  code: "dirty",
@@ -463,6 +472,7 @@ async function begin(
463
472
  code: "unknown",
464
473
  error: `Couldn't look for updates: ${result.error}`,
465
474
  };
475
+ await discardGenerated(deps);
466
476
  const refused = await preflight(result, deps);
467
477
  if (refused) return { ok: false, ...refused };
468
478
  if (when === "idle") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.826",
3
+ "version": "0.3.828",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -51,6 +51,10 @@ published=$(cat "$state/published" 2>/dev/null || true)
51
51
  [ "$head" = "$deployed" ] && [ "$head" = "$published" ] && exit 0
52
52
  log() { echo "$(date '+%Y-%m-%d %-I:%M %p') $*"; }
53
53
 
54
+ # shellcheck source=lib/generated.sh
55
+ source scripts/lib/generated.sh
56
+ discard_generated
57
+
54
58
  # A head that differs from the deployed one only in the changelog file needs
55
59
  # a pull, not a build or a restart: the file is read from disk when asked.
56
60
  changelog=content/whats-new.json
@@ -22,7 +22,12 @@ function setup() {
22
22
  fs.mkdirSync(path.join(work, "scripts"));
23
23
  fs.copyFileSync(script, path.join(work, "scripts/autodeploy"));
24
24
  fs.mkdirSync(path.join(work, "scripts/lib"));
25
- for (const f of ["sync-cli-links", "lib/cli-links.sh"])
25
+ for (const f of [
26
+ "sync-cli-links",
27
+ "lib/cli-links.sh",
28
+ "lib/generated.sh",
29
+ "generated-files.txt",
30
+ ])
26
31
  fs.copyFileSync(path.join(__dirname, f), path.join(work, "scripts", f));
27
32
  fs.chmodSync(path.join(work, "scripts/sync-cli-links"), 0o755);
28
33
  fs.mkdirSync(path.join(work, "bin"));
@@ -0,0 +1,3 @@
1
+ # Files the build or dev server rewrites in a checkout. The updater and the
2
+ # deploy scripts discard changes to these before pulling; one path per line.
3
+ next-env.d.ts
@@ -0,0 +1,77 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { tempDir } from "@/lib/test-temp";
6
+
7
+ // discard_generated in a real clone: a build-modified generated file must not
8
+ // block a pull that deletes it upstream, and a real local edit still does.
9
+ const git = (cwd: string, ...args: string[]) =>
10
+ execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], {
11
+ cwd,
12
+ encoding: "utf-8",
13
+ });
14
+
15
+ function setup() {
16
+ const root = tempDir("aos-generated-");
17
+ const origin = path.join(root, "origin");
18
+ const seed = path.join(root, "seed");
19
+ const work = path.join(root, "work");
20
+ git(root, "init", "-q", "-b", "main", seed);
21
+ fs.mkdirSync(path.join(seed, "scripts/lib"), { recursive: true });
22
+ for (const f of ["generated-files.txt", "lib/generated.sh"])
23
+ fs.copyFileSync(
24
+ path.join(import.meta.dirname, f),
25
+ path.join(seed, "scripts", f)
26
+ );
27
+ fs.writeFileSync(path.join(seed, "next-env.d.ts"), "dev\n");
28
+ fs.writeFileSync(path.join(seed, "app.ts"), "a\n");
29
+ git(seed, "add", ".");
30
+ git(seed, "commit", "-qm", "first");
31
+ git(root, "clone", "-q", "--bare", seed, origin);
32
+ git(root, "clone", "-q", origin, work);
33
+ // Upstream stops tracking the generated file.
34
+ git(seed, "remote", "add", "origin", origin);
35
+ git(seed, "rm", "-q", "--cached", "next-env.d.ts");
36
+ git(seed, "commit", "-qm", "untrack");
37
+ git(seed, "push", "-q", "origin", "main");
38
+ return work;
39
+ }
40
+
41
+ const discard = (cwd: string) =>
42
+ execFileSync(
43
+ "bash",
44
+ ["-c", "source scripts/lib/generated.sh && discard_generated"],
45
+ { cwd, encoding: "utf-8" }
46
+ );
47
+
48
+ describe("discard_generated", () => {
49
+ it("restores a build-modified generated file so the pull goes through", () => {
50
+ const work = setup();
51
+ fs.writeFileSync(path.join(work, "next-env.d.ts"), "build\n");
52
+ discard(work);
53
+ expect(git(work, "status", "--porcelain", "--untracked-files=no")).toBe("");
54
+ git(work, "pull", "-q", "--ff-only");
55
+ expect(git(work, "ls-files", "next-env.d.ts")).toBe("");
56
+ });
57
+
58
+ it("leaves real local changes in place", () => {
59
+ const work = setup();
60
+ fs.writeFileSync(path.join(work, "next-env.d.ts"), "build\n");
61
+ fs.writeFileSync(path.join(work, "app.ts"), "edited\n");
62
+ discard(work);
63
+ expect(git(work, "status", "--porcelain", "--untracked-files=no")).toBe(
64
+ " M app.ts\n"
65
+ );
66
+ });
67
+
68
+ it("leaves an untracked generated file alone", () => {
69
+ const work = setup();
70
+ git(work, "pull", "-q", "--ff-only");
71
+ fs.writeFileSync(path.join(work, "next-env.d.ts"), "build\n");
72
+ discard(work);
73
+ expect(fs.readFileSync(path.join(work, "next-env.d.ts"), "utf-8")).toBe(
74
+ "build\n"
75
+ );
76
+ });
77
+ });
@@ -7,6 +7,9 @@
7
7
  # .next-prev for one rollback. A failed build never touches .next.
8
8
  # Sourced by scripts/redeploy; run from the repository root.
9
9
 
10
+ # shellcheck source=generated.sh
11
+ source scripts/lib/generated.sh
12
+
10
13
  LIVE=.next
11
14
  NEXT_BUILD=.next-build
12
15
  PREV=.next-prev
@@ -47,6 +50,7 @@ build_beside() {
47
50
  [ -d "$LIVE/types.building" ] && mv "$LIVE/types.building" "$LIVE/types"
48
51
  for f in "${REWRITTEN[@]}"; do [ -f "$saved/$f" ] && cp -p "$saved/$f" "$f"; done
49
52
  rm -rf "$saved"
53
+ discard_generated
50
54
  # `next build` exits non-zero on a type error, so a build that got here
51
55
  # typechecked; a missing BUILD_ID means it stopped short anyway.
52
56
  if [ "$status" = 0 ] && [ ! -s "$NEXT_BUILD/BUILD_ID" ]; then
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # Generated files (scripts/generated-files.txt) are rewritten by `next build`
3
+ # and `next dev`, which would leave the checkout dirty and make a pull fail.
4
+ # Sourced by scripts/redeploy and scripts/autodeploy; run from the repository root.
5
+
6
+ # Put every tracked, modified generated file back to HEAD. Untracked ones are
7
+ # left alone.
8
+ discard_generated() {
9
+ local f
10
+ [ -f scripts/generated-files.txt ] || return 0
11
+ while IFS= read -r f; do
12
+ case "$f" in ''|'#'*) continue ;; esac
13
+ if git ls-files --error-unmatch -- "$f" >/dev/null 2>&1 &&
14
+ [ -n "$(git status --porcelain --untracked-files=no -- "$f")" ]; then
15
+ git checkout -q -- "$f" || true
16
+ fi
17
+ done < scripts/generated-files.txt
18
+ }
package/scripts/redeploy CHANGED
@@ -38,6 +38,7 @@ check_disk() {
38
38
  # The pull can replace this script, so what follows runs from a fresh start.
39
39
  if [ "${1:-}" != "--rollback" ] && [ "${1:-}" != "--pulled" ]; then
40
40
  check_disk
41
+ discard_generated
41
42
  echo "::phase pulling"
42
43
  if [ -n "${AGENTOS_UPDATE_TO:-}" ]; then
43
44
  git fetch -q origin
@@ -20,6 +20,7 @@ beforeEach(() => {
20
20
  for (const f of [
21
21
  "redeploy",
22
22
  "lib/build-swap.sh",
23
+ "lib/generated.sh",
23
24
  "lib/pnpm.sh",
24
25
  "lib/deps-swap.sh",
25
26
  ])