Repository: val-town/vt
Pull request #18: Add 'push' and 'watch' commands
Author: login: 404Wolf
Created: 2025-03-10T18:58:05Z | Merged: 2025-03-13T15:58:26Z

Add a 'push' command, and a 'watch' command.

Push is pretty simple:
1) Get the status
2) For all the created files create them
3) For all the deleted files delete them
4) For all the modified files modify them
- Along the way, make sure that the directories of files are there recursively

Watch is also straightforward. It is actually a ~~BI~~UNI directional sync (local -> remote).
~~1) If --daemon, rerun as a new process that is a daemon without --daemon.~~
2) Use `Deno.watchFs` with a debounce to watch for file system changes for local edits. On a local edit, basically just call push, which calls status. This is not the most efficient way in the world to do it but it saves us from a lot of complicated debouncing and bookkeeping issues (like, deno's debounce isn't operation (e.g. create) specific, and in general figuring out which events you want to check for can be annoying. Plus status offers useful heuristics like mtime to avoid API requests.
~~3) Every X seconds (default 2) pull from val town for remote changes.~~

~~If running as a daemon, they can do `vt watch --daemon` and it will exit right away and keep watching. They can close their terminal or whatever. Then if in the directory again they can do `vt watch stop` to kill the watching process. There's a lockfile so multiple watchers shouldn't run at once.~~

Finally, this PR also removes the old `foobar.script.tsx` syntax for val types. Instead, it uses the following algorithm
```ts
/**
 * 1. Check if the file already exists in the project at the specified path.
 * 2. If the file does not exist, determine its type based on its file extension:
 *    - Files ending in .ts, .tsx, .js, or .jsx are considered "val" files.
 *    - Check the filename for keywords like "cron", "http", or "email" to
 *      determine specific types:
 *      - If multiple keywords are found, default to "script".
 *      - Otherwise, return "interval" for "cron", "http" for "http", etc
 *      - Default to "script" if no keywords are found.
 * 3. If the file does not match the val extension criteria (.ts + optional
 *    identifier), return "file".
 */
```


Diff:
diff --git a/deno.json b/deno.json
index 8ea5219e..41c1e40a 100644
--- a/deno.json
+++ b/deno.json
@@ -11,6 +11,7 @@
     "@cliffy/ansi": "jsr:@cliffy/ansi@^1.0.0-rc.7",
     "@cliffy/table": "jsr:@cliffy/table@1.0.0-rc.7",
     "@deno-library/logger": "jsr:@deno-library/logger@^1.1.9",
+    "@std/async": "jsr:@std/async@^1.0.11",
     "@std/fs": "jsr:@std/fs@^1.0.13",
     "@std/path": "jsr:@std/path@^1.0.8",
     "zod": "npm:zod@^3.24.2",
@@ -19,7 +20,7 @@
     "@std/assert": "jsr:@std/assert@1",
     "@std/dotenv": "jsr:@std/dotenv@^0.225.3",
     "@std/encoding": "jsr:@std/encoding@^1.0.7",
-    "@valtown/sdk": "jsr:@valtown/sdk@^0.32.0",
+    "@valtown/sdk": "jsr:@valtown/sdk@^0.36.0",
     "emphasize": "npm:emphasize@^7.0.0",
     "highlight.js": "npm:highlight.js@^11.11.1",
     "kia": "https://deno.land/x/kia@0.4.1/mod.ts"
diff --git a/deno.lock b/deno.lock
index 7966e166..6e2aed6b 100644
--- a/deno.lock
+++ b/deno.lock
@@ -7,23 +7,22 @@
     "jsr:@cliffy/internal@1.0.0-rc.7": "1.0.0-rc.7",
     "jsr:@cliffy/table@1.0.0-rc.7": "1.0.0-rc.7",
     "jsr:@deno-library/logger@^1.1.9": "1.1.9",
-    "jsr:@std/assert@*": "1.0.11",
     "jsr:@std/assert@1": "1.0.11",
+    "jsr:@std/async@*": "1.0.11",
+    "jsr:@std/async@^1.0.11": "1.0.11",
     "jsr:@std/dotenv@~0.225.3": "0.225.3",
     "jsr:@std/encoding@^1.0.7": "1.0.7",
     "jsr:@std/encoding@~1.0.5": "1.0.7",
     "jsr:@std/fmt@1.0.3": "1.0.3",
-    "jsr:@std/fmt@~1.0.2": "1.0.5",
-    "jsr:@std/fs@*": "1.0.13",
-    "jsr:@std/fs@^1.0.13": "1.0.13",
-    "jsr:@std/fs@^1.0.6": "1.0.13",
+    "jsr:@std/fmt@~1.0.2": "1.0.6",
+    "jsr:@std/fs@^1.0.13": "1.0.14",
+    "jsr:@std/fs@^1.0.6": "1.0.14",
     "jsr:@std/internal@^1.0.5": "1.0.5",
     "jsr:@std/io@~0.224.9": "0.224.9",
-    "jsr:@std/path@*": "1.0.8",
     "jsr:@std/path@^1.0.8": "1.0.8",
-    "jsr:@std/text@~1.0.7": "1.0.10",
-    "jsr:@valtown/sdk@*": "0.32.0",
-    "jsr:@valtown/sdk@0.32": "0.32.0",
+    "jsr:@std/text@~1.0.7": "1.0.11",
+    "jsr:@valtown/sdk@0.36": "0.36.0",
+    "npm:@types/node@*": "22.12.0",
     "npm:emphasize@7": "7.0.0",
     "npm:highlight.js@^11.11.1": "11.11.1",
     "npm:zod@^3.24.2": "3.24.2"
@@ -76,6 +75,9 @@
         "jsr:@std/internal"
       ]
     },
+    "@std/async@1.0.11": {
+      "integrity": "eee0d3405275506638a9c8efaa849cf0d35873120c69b7caa1309c9a9e5b6f85"
+    },
     "@std/dotenv@0.225.3": {
       "integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a"
     },
@@ -85,13 +87,13 @@
     "@std/fmt@1.0.3": {
       "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f"
     },
-    "@std/fmt@1.0.5": {
-      "integrity": "0cfab43364bc36650d83c425cd6d99910fc20c4576631149f0f987eddede1a4d"
+    "@std/fmt@1.0.6": {
+      "integrity": "a2c56a69a2369876ddb3ad6a500bb6501b5bad47bb3ea16bfb0c18974d2661fc"
     },
-    "@std/fs@1.0.13": {
-      "integrity": "756d3ff0ade91c9e72b228e8012b6ff00c3d4a4ac9c642c4dac083536bf6c605",
+    "@std/fs@1.0.14": {
+      "integrity": "1e84bf0b95fe08f41f1f4aea9717bbf29f45408a56ce073b0114474ce1c9fccf",
       "dependencies": [
-        "jsr:@std/path@^1.0.8"
+        "jsr:@std/path"
       ]
     },
     "@std/internal@1.0.5": {
@@ -103,11 +105,11 @@
     "@std/path@1.0.8": {
       "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
     },
-    "@std/text@1.0.10": {
-      "integrity": "9dcab377450253c0efa9a9a0c731040bfd4e1c03f8303b5934381467b7954338"
+    "@std/text@1.0.11": {
+      "integrity": "f191fa22590cac8b1cdba6cc4ab97940e720f7cc67b3084e54405b428bf5843d"
     },
-    "@valtown/sdk@0.32.0": {
-      "integrity": "36a3cea27bdc17881cd0de44e59b7bed05ed2b0166029c71421c5c9cc00b81b2"
+    "@valtown/sdk@0.36.0": {
+      "integrity": "00df2c1de39b8c2fd6340779bf93c30b021fc295b99d90529a4be2011fb9d18d"
     }
   },
   "npm": {
@@ -117,6 +119,12 @@
         "@types/unist"
       ]
     },
+    "@types/node@22.12.0": {
+      "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==",
+      "dependencies": [
+        "undici-types"
+      ]
+    },
     "@types/unist@3.0.3": {
       "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
     },
@@ -155,6 +163,9 @@
         "highlight.js@11.9.0"
       ]
     },
+    "undici-types@6.20.0": {
+      "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
+    },
     "zod@3.24.2": {
       "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="
     }
@@ -189,11 +200,12 @@
       "jsr:@cliffy/table@1.0.0-rc.7",
       "jsr:@deno-library/logger@^1.1.9",
       "jsr:@std/assert@1",
+      "jsr:@std/async@^1.0.11",
       "jsr:@std/dotenv@~0.225.3",
       "jsr:@std/encoding@^1.0.7",
       "jsr:@std/fs@^1.0.13",
       "jsr:@std/path@^1.0.8",
-      "jsr:@valtown/sdk@0.32",
+      "jsr:@valtown/sdk@0.36",
       "npm:emphasize@7",
       "npm:highlight.js@^11.11.1",
       "npm:zod@^3.24.2"
diff --git a/src/cmd/git.ts b/src/cmd/git.ts
index 43eec320..a5f62238 100644
--- a/src/cmd/git.ts
+++ b/src/cmd/git.ts
@@ -220,6 +220,25 @@ const branchCmd = new Command()
     }
   });
 
+const pushCmd = new Command()
+  .name("push")
+  .description("Push local changes to a val town project")
+  .action(async () => {
+    const spinner = new Kia("Pushing local changes...");
+    const cwd = Deno.cwd();
+
+    try {
+      spinner.start();
+      const vt = VTClient.from(cwd);
+      await vt.push(cwd);
+      spinner.succeed(`Project pushed successfully from ${cwd}`);
+    } catch (error) {
+      if (error instanceof Error) {
+        spinner.fail(error.message);
+      }
+    }
+  });
+
 const checkoutCmd = new Command()
   .name("checkout")
   .description("Check out a different branch")
@@ -297,4 +316,4 @@ const checkoutCmd = new Command()
     },
   );
 
-export { branchCmd, checkoutCmd, cloneCmd, pullCmd, statusCmd };
+export { branchCmd, checkoutCmd, cloneCmd, pullCmd, pushCmd, statusCmd };
diff --git a/src/cmd/root.ts b/src/cmd/root.ts
index fb2d0da9..c1ffbc97 100644
--- a/src/cmd/root.ts
+++ b/src/cmd/root.ts
@@ -1,6 +1,7 @@
 import { Command } from "@cliffy/command";
 import manifest from "../../deno.json" with { type: "json" };
 import * as cmds from "~/cmd/git.ts";
+import { watchCmd } from "~/cmd/watch.ts";
 import VTClient from "~/vt/vt/VTClient.ts";
 import { basename, join } from "@std/path";
 import { user } from "~/sdk.ts";
@@ -91,8 +92,10 @@ const createCmd = new Command()
 
 cmd.command("clone", cmds.cloneCmd);
 cmd.command("pull", cmds.pullCmd);
+cmd.command("push", cmds.pushCmd);
 cmd.command("status", cmds.statusCmd);
 cmd.command("branch", cmds.branchCmd);
+cmd.command("watch", watchCmd);
 cmd.command("checkout", cmds.checkoutCmd);
 cmd.command("create", createCmd);
 
diff --git a/src/cmd/watch.ts b/src/cmd/watch.ts
new file mode 100644
index 00000000..4ecd7322
--- /dev/null
+++ b/src/cmd/watch.ts
@@ -0,0 +1,54 @@
+import { Command } from "@cliffy/command";
+import Kia from "kia";
+import VTClient from "~/vt/vt/VTClient.ts";
+
+export const watchStopCmd = new Command()
+  .name("watch stop")
+  .description("Stop the watch daemon process")
+  .action(async () => {
+    const cwd = Deno.cwd();
+    const vt = VTClient.from(cwd);
+    const kia = new Kia("Stopping the watch process...");
+    kia.start();
+
+    try {
+      const pidStr = await vt.meta.getLockFile();
+      if (pidStr) {
+        const pid = parseInt(pidStr, 10);
+        if (!isNaN(pid)) {
+          Deno.kill(pid);
+          kia.succeed(`Stopped watch process with PID: ${pid}`);
+        } else {
+          kia.fail("Invalid PID in lockfile.");
+        }
+      } else {
+        kia.fail("No running watch process found.");
+      }
+    } catch (error) {
+      kia.fail("Failed to stop the watch process.");
+      console.error(error);
+    }
+  });
+
+export const watchCmd = new Command()
+  .name("watch")
+  .description("Watch for changes and automatically sync with Val Town")
+  .action(async () => {
+    const cwd = Deno.cwd();
+
+    const spinner = new Kia("Starting watch mode...");
+    try {
+      spinner.start();
+      const vt = VTClient.from(cwd);
+
+      spinner.info("Watching for file changes...");
+      spinner.info("Press Ctrl+C to stop");
+
+      await vt.watch();
+    } catch (error) {
+      if (error instanceof Error) {
+        spinner.fail(error.message);
+      }
+    }
+  })
+  .command("stop", watchStopCmd);
diff --git a/src/consts.ts b/src/consts.ts
index 233cea8d..ff8ab02a 100644
--- a/src/consts.ts
+++ b/src/consts.ts
@@ -12,6 +12,7 @@ export const DEFAULT_IGNORE_PATTERNS: string[] = [
 export const CONFIG_FILE_NAME = "vt.json";
 export const META_FOLDER_NAME = ".vt";
 export const META_IGNORE_FILE_NAME = ".vtignore";
+export const META_LOCK_FILE_NAME = "lock";
 
 export const STATUS_COLORS: Record<
   string,
@@ -23,12 +24,12 @@ export const STATUS_COLORS: Record<
   not_modified: { prefix: " ", color: colors.gray },
 };
 
-export const VAL_TYPE_EXTENSIONS: Record<
-  string,
-  { abbreviated: string; standard: string }
-> = {
-  "script": { abbreviated: "S", standard: "script" },
-  "http": { abbreviated: "H", standard: "http" },
-  "email": { abbreviated: "E", standard: "email" },
-  "interval": { abbreviated: "C", standard: "cron" },
-};
+export const DEFAULT_VAL_TYPE = "script";
+
+export type ProjectItem =
+  | "script"
+  | "http"
+  | "email"
+  | "interval"
+  | "file"
+  | "directory";
diff --git a/src/sdk.ts b/src/sdk.ts
index e8ca2383..8496bb51 100644
--- a/src/sdk.ts
+++ b/src/sdk.ts
@@ -17,12 +17,44 @@ const sdk = new ValTown({
 async function branchNameToId(
   projectId: string,
   branchName: string,
-): Promise<string> {
+): Promise<ValTown.Projects.Branches.BranchListResponse> {
   for await (const branch of sdk.projects.branches.list(projectId, {})) {
-    if (branch.name == branchName) return branch.id;
+    if (branch.name == branchName) return branch;
   }
 
-  throw new Error(`Branch "${branchName}" not found in project ${projectId}`);
+  throw new Deno.errors.NotFound(
+    `Branch "${branchName}" not found in project ${projectId}`,
+  );
+}
+
+/**
+ * Converts a file path to its corresponding project file for a given project.
+ *
+ * @param {string} projectId The ID of the project containing the file
+ * @param {string} branchId The ID of the project branch to reference
+ * @param {number} version The version of the project for the file being found
+ * @param {string} filePath The file path to locate
+ * @returns {Promise} Promise resolving to the branch ID
+ * @throws {Error} if the branch is not found or if the API request fails
+ */
+async function filePathToFile(
+  projectId: string,
+  branchId: string,
+  version: number,
+  filePath: string,
+): Promise<ValTown.Projects.Files.FileListResponse> {
+  for await (
+    const file of sdk.projects.files.list(projectId, {
+      version,
+      branch_id: branchId,
+    })
+  ) {
+    if (file.name == filePath) return file;
+  }
+
+  throw new Deno.errors.NotFound(
+    `Branch "${filePath}" not found in project ${projectId}`,
+  );
 }
 
 /**
@@ -34,5 +66,5 @@ export async function getLatestVersion(projectId: string, branchId: string) {
 
 const user = await sdk.me.profile.retrieve();
 
-export { branchNameToId, user };
+export { branchNameToId, filePathToFile, user };
 export default sdk;
diff --git a/src/vt/git/clone.ts b/src/vt/git/clone.ts
index be95114f..35542f0e 100644
--- a/src/vt/git/clone.ts
+++ b/src/vt/git/clone.ts
@@ -1,8 +1,7 @@
 import sdk from "~/sdk.ts";
 import type Valtown from "@valtown/sdk";
-import { withValExtension } from "~/vt/git/paths.ts";
 import { removeEmptyDirs } from "~/utils.ts";
-import { shouldIgnoreGlob } from "~/vt/git/paths.ts";
+import { shouldIgnore } from "~/vt/git/paths.ts";
 import * as path from "@std/path";
 import { ensureDir } from "@std/fs";
 import type ValTown from "@valtown/sdk";
@@ -47,7 +46,7 @@ export function clone(
       // ignored files). Then we run it on all the files.
       const clonePromises = files
         .filter((file) => file.type !== "directory") // we'll create directories when creating files
-        .filter((file) => !shouldIgnoreGlob(file.path, ignoreGlobs))
+        .filter((file) => !shouldIgnore(file.path, ignoreGlobs))
         .map(
           // Function to run on files (if they aren't ignored)
           (file: Valtown.Projects.FileListResponse) => {
@@ -71,22 +70,17 @@ async function createFile(
   version: number,
   file: Valtown.Projects.FileListResponse,
 ): Promise<void> {
-  const fullPath = path.join(
-    path.dirname(rootPath),
-    file.type === "file" // "file" indicates it is NOT a val
-      ? file.name
-      : withValExtension(file.name, file.type),
-  );
+  const fullPath = path.join(path.dirname(rootPath), file.name);
 
   // Add all needed parents for creating the file
   await ensureDir(path.dirname(fullPath));
 
   // Get and write the file content
-  const content = await (await sdk.projects.files.content(
+  const content = await sdk.projects.files.getContent(
     projectId,
     encodeURIComponent(file.path),
     { branch_id: branchId, version },
-  )).text();
+  ).then((resp) => resp.text());
 
   await ensureDir(path.dirname(fullPath));
   await Deno.writeTextFile(fullPath, content);
diff --git a/src/vt/git/paths.ts b/src/vt/git/paths.ts
index c4d1e166..7960b99c 100644
--- a/src/vt/git/paths.ts
+++ b/src/vt/git/paths.ts
@@ -1,48 +1,68 @@
 import * as path from "@std/path";
-import { VAL_TYPE_EXTENSIONS } from "~/consts.ts";
+import { DEFAULT_VAL_TYPE, ProjectItem } from "~/consts.ts";
+import { filePathToFile } from "~/sdk.ts";
 
 /**
- * Adds val file extension to a filename
+ * Determine the type of a project file.
  *
- * @param filename Base filename
- * @param type Val file type (script, http, ...)
- * @param abbreviated Whether to use val file extension (default: false)
- * @returns Filename with val file extension
+ * This function attempts to determine the type of a file within a project
+ * based on its existing state on the server or its filename. The process...
+ * 1. Check if the file already exists in the project at the specified path.
+ * 2. If the file does not exist, determine its type based on its file extension:
+ *    - Files ending in .ts, .tsx, .js, or .jsx are considered "val" files.
+ *    - Check the filename for keywords like "cron", "http", or "email" to
+ *      determine specific types:
+ *      - If multiple keywords are found, default to "script".
+ *      - Otherwise, return "interval" for "cron", "http" for "http", etc
+ *      - Default to "script" if no keywords are found.
+ * 3. If the file does not match the val extension criteria (.ts + optional
+ *    identifier), return "file".
+ *
+ * @param filepath Path or filename to analyze
+ * @returns The val file type
  */
-function withValExtension(
-  filename: string,
-  type: keyof typeof VAL_TYPE_EXTENSIONS,
-  abbreviated: boolean = false,
-): string {
-  const extension = abbreviated
-    ? `.${VAL_TYPE_EXTENSIONS[type].abbreviated}.tsx`
-    : `.${VAL_TYPE_EXTENSIONS[type].standard}.tsx`;
+async function getProjectItemType(
+  projectId: string,
+  branchId: string,
+  version: number,
+  filePath: string,
+): Promise<ProjectItem> {
+  try {
+    // If a file already exists in the project at the given path, then the type
+    // is whatever it already is on the website.
+    return await filePathToFile(projectId, branchId, version, filePath)
+      .then((resp) => resp.type);
+  } catch (e) {
+    // Otherwise, if it ends in .ts, .js, .tsx, or .jsx, it is a val
+    if (e instanceof Deno.errors.NotFound) {
+      if (/\.(ts|tsx|js|jsx)$/.test(filePath)) {
+        const isCron = filePath.includes("cron");
+        const isHttp = filePath.includes("http");
+        const isEmail = filePath.includes("email");
 
-  const baseFilename = withoutValExtension(filename);
+        // If it's ambiguous then it is a script val by default
+        if ([isCron, isHttp, isEmail].filter(Boolean).length > 1) {
+          return DEFAULT_VAL_TYPE;
+        }
 
-  return baseFilename + extension;
-}
+        // But otherwise look at the file name and try to figure out what type
+        // of val it is based on whether the file name contains a pattern like
+        // "cron," etc
+        if (isCron) return "interval";
+        if (isHttp) return "http";
+        if (isEmail) return "email";
 
-/**
- * Removes val file extension from a filename if present
- * @param filename Filename with possible val file extension
- * @param abbreviated Whether to check for val file extension (default: false)
- * @returns Filename without val file extension
- */
-function withoutValExtension(
-  filename: string,
-  abbreviated: boolean = false,
-): string {
-  const extensions = Object.values(VAL_TYPE_EXTENSIONS).map(
-    (ext) => abbreviated ? `.${ext.abbreviated}.tsx` : `.${ext.standard}.tsx`,
-  );
+        // If we can't figure it out, default to script
+        return DEFAULT_VAL_TYPE;
+      }
 
-  for (const extension of extensions) {
-    if (filename.endsWith(extension)) {
-      return filename.slice(0, -extension.length);
+      // Otherwise, it's just a plain old file val
+      return "file";
+    } else {
+      // Re-throw any other errors
+      throw e;
     }
   }
-  return filename;
 }
 
 /**
@@ -64,7 +84,7 @@ function createIgnorePatterns(ignoreGlobs: string[]): RegExp[] {
  * @param {string[]} ignoreGlobs Array of glob patterns to check against
  * @returns {boolean} True if the path should be ignored
  */
-function shouldIgnoreGlob(
+function shouldIgnore(
   filePath: string,
   ignoreGlobs: string[] = [],
 ): boolean {
@@ -73,4 +93,4 @@ function shouldIgnoreGlob(
   );
 }
 
-export { shouldIgnoreGlob, withoutValExtension, withValExtension };
+export { getProjectItemType, shouldIgnore };
diff --git a/src/vt/git/push.ts b/src/vt/git/push.ts
new file mode 100644
index 00000000..db6d639e
--- /dev/null
+++ b/src/vt/git/push.ts
@@ -0,0 +1,156 @@
+import { status } from "~/vt/git/status.ts";
+import * as path from "@std/path";
+import sdk, { getLatestVersion } from "~/sdk.ts";
+import { getProjectItemType } from "~/vt/git/paths.ts";
+import ValTown from "@valtown/sdk";
+import { ProjectItem } from "~/consts.ts";
+
+/**
+ * Pushes latest changes from a vt folder into a Val Town project.
+ *
+ * @param args Options for pull operation.
+ * @param {string} args.targetDir The vt project root directory.
+ * @param {string} args.projectId The id of the project to upload to.
+ * @param {string} args.branchId The branch ID to upload file content to.
+ * @param {string} args.branchId The version to compute the status against. Defaults to latest version.
+ * @param {string[]} args.ignoreGlobs A list of glob patterns for files to exclude.
+ *
+ * @returns Promise that resolves when the push operation is complete.
+ */
+export async function push({
+  targetDir,
+  projectId,
+  branchId,
+  version,
+  ignoreGlobs,
+}: {
+  targetDir: string;
+  projectId: string;
+  branchId: string;
+  version?: number;
+  ignoreGlobs: string[];
+}): Promise<void> {
+  version = version || await getLatestVersion(projectId, branchId);
+
+  const statusResult = await status({
+    version,
+    targetDir,
+    projectId,
+    branchId,
+    ignoreGlobs,
+  });
+
+  // Upload everything that was modified
+  for (const file of statusResult.modified) {
+    let type: ProjectItem | undefined = await getProjectItemType(
+      projectId,
+      branchId,
+      version,
+      file.path,
+    );
+    if (type === "directory") type = undefined;
+
+    await sdk.projects.files.update(
+      projectId,
+      encodeURIComponent(file.path),
+      {
+        branch_id: branchId,
+        content: await Deno.readTextFile(path.join(targetDir, file.path)),
+        name: path.basename(file.path),
+        type,
+      },
+    );
+  }
+
+  // Delete everything that was deleted
+  for (const file of statusResult.deleted) {
+    await sdk.projects.files.delete(projectId, file.path, {
+      branch_id: branchId,
+      version,
+    });
+  }
+
+  // Create all new files
+  for (const file of statusResult.created) {
+    const type = await getProjectItemType(
+      projectId,
+      branchId,
+      version,
+      file.path,
+    );
+
+    // Ensure parent directories exist before creating the file
+    await ensureValtownDir(
+      projectId,
+      branchId,
+      file.path,
+    );
+
+    try {
+      if (type === "directory") {
+        // We already ensured the directory path exists
+      } else {
+        await sdk.projects.files.create(
+          projectId,
+          encodeURIComponent(file.path),
+          {
+            content: (await Deno.readTextFile(path.join(targetDir, file.path))),
+            branch_id: branchId,
+            type,
+          },
+        );
+      }
+    } catch (error) {
+      assertAllowedUploadError(error);
+    }
+  }
+}
+
+async function ensureValtownDir(
+  projectId: string,
+  branchId: string,
+  filePath: string,
+): Promise<void> {
+  const dirPath = path.dirname(filePath);
+
+  // If path is "." (current directory) or empty, no directories need to be created
+  if (dirPath === "." || dirPath === "") {
+    return;
+  }
+
+  // Split the path into segments
+  const segments = dirPath.split("/");
+  let currentPath = "";
+
+  // Create each directory in the path if it doesn't exist
+  for (let i = 0; i < segments.length; i++) {
+    if (segments[i] === "") continue;
+
+    currentPath += (currentPath ? "/" : "") + segments[i];
+
+    // Create directory - content can be null, empty string, or omitted for directories
+    try {
+      await sdk.projects.files.create(
+        projectId,
+        encodeURIComponent(currentPath),
+        {
+          type: "directory",
+          branch_id: branchId,
+          content: null,
+        },
+      );
+    } catch (error) {
+      assertAllowedUploadError(error);
+    }
+  }
+}
+
+function assertAllowedUploadError(error: unknown) {
+  if (error instanceof ValTown.APIError) {
+    if (error.status != 409) {
+      throw error;
+    }
+  } else {
+    throw error;
+  }
+}
diff --git a/src/vt/git/status.ts b/src/vt/git/status.ts
index cce8bdb6..e21bd34b 100644
--- a/src/vt/git/status.ts
+++ b/src/vt/git/status.ts
@@ -1,7 +1,6 @@
 import sdk from "~/sdk.ts";
 import type ValTown from "@valtown/sdk";
-import { withoutValExtension, withValExtension } from "~/vt/git/paths.ts";
-import { shouldIgnoreGlob } from "~/vt/git/paths.ts";
+import { shouldIgnore } from "~/vt/git/paths.ts";
 import * as fs from "@std/fs";
 import * as path from "@std/path";
 
@@ -124,16 +123,16 @@ async function isFileModified(
   branchId: string,
   version: number,
 ): Promise<boolean> {
-  const projectFileContent = await sdk.projects.files.content(
+  const projectFileContent = await sdk.projects.files.getContent(
     projectId,
-    encodeURIComponent(withoutValExtension(cleanPath)),
+    encodeURIComponent(cleanPath),
     { branch_id: branchId, version },
   ).then((resp) => resp.text());
 
   // For some reason the local paths seem to have an extra newline
-  const localFileContent = (await Deno.readTextFile(
+  const localFileContent = await Deno.readTextFile(
     path.join(targetDir, originalPath),
-  )).slice(0, -1);
+  );
 
   return projectFileContent !== localFileContent;
 }
@@ -154,17 +153,10 @@ async function getProjectFiles(
   for await (const file of projectFilesResponse.data) files.push(file);
 
   const processedFiles = files
-    .filter((file) => !shouldIgnoreGlob(file.path, ignoreGlobs))
+    .filter((file) => !shouldIgnore(file.path, ignoreGlobs))
     .filter((file) => file.type !== "directory")
-    .map((
-      file: ValTown.Projects.FileListResponse,
-    ) => [
-      path.join(
-        path.dirname(file.path),
-        file.type === "file"
-          ? file.name
-          : withValExtension(file.name, file.type),
-      ),
+    .map((file: ValTown.Projects.FileListResponse) => [
+      path.join(path.dirname(file.path), file.name),
       new Date(file.updatedAt).getTime(),
     ]) as [string, number][];
 
@@ -184,7 +176,7 @@ async function getLocalFiles(
 
     // Check if this is on the ignore list
     const relativePath = path.relative(targetDir, entry.path);
-    if (shouldIgnoreGlob(relativePath, ignoreGlobs)) return;
+    if (shouldIgnore(relativePath, ignoreGlobs)) return;
 
     // Stat the file to get the modification time
     const stat = await Deno.stat(entry.path);
diff --git a/src/vt/git/tests/cases.ts b/src/vt/git/tests/cases.ts
index ae431671..f900128a 100644
--- a/src/vt/git/tests/cases.ts
+++ b/src/vt/git/tests/cases.ts
@@ -29,12 +29,12 @@ export const testCases: TestCase[] = [
         version: 8,
         expectedInodes: [
           {
-            path: "proudLimeGoose.http.tsx",
+            path: "proudLimeGoose",
             type: "file",
             content: "// Example Content",
           },
           {
-            path: "merryCopperAsp.script.tsx",
+            path: "merryCopperAsp",
             type: "file",
             content: "",
           },
@@ -43,7 +43,7 @@ export const testCases: TestCase[] = [
             type: "directory",
           },
           {
-            path: "thoughtfulPeachPrimate/clearAquamarineSmelt.cron.tsx",
+            path: "thoughtfulPeachPrimate/clearAquamarineSmelt",
             type: "file",
             content: 'const test = "test";',
           },
@@ -55,16 +55,16 @@ export const testCases: TestCase[] = [
         ],
         modifiedFiles: [
           {
-            path: "proudLimeGoose.http.tsx",
+            path: "proudLimeGoose",
             newContent: "// Modified Example Content",
           },
           {
-            path: "thoughtfulPeachPrimate/clearAquamarineSmelt.cron.tsx",
+            path: "thoughtfulPeachPrimate/clearAquamarineSmelt",
             newContent: 'const test = "modified test";',
           },
         ],
         deletedFiles: [
-          "merryCopperAsp.script.tsx",
+          "merryCopperAsp",
           "thoughtfulPeachPrimate/tirelessHarlequinSmelt",
         ],
       },
@@ -72,12 +72,12 @@ export const testCases: TestCase[] = [
         version: 4,
         expectedInodes: [
           {
-            path: "Test.script.tsx",
+            path: "Test",
             type: "file",
             content: "test;",
           },
           {
-            path: "TestBranch.http.tsx",
+            path: "TestBranch",
             type: "file",
             content: "// Example Content",
           },
diff --git a/src/vt/git/tests/checkout_test.ts b/src/vt/git/tests/checkout_test.ts
index 5c55ec64..4e0a1aae 100644
--- a/src/vt/git/tests/checkout_test.ts
+++ b/src/vt/git/tests/checkout_test.ts
@@ -1,5 +1,5 @@
 import { clone } from "~/vt/git/clone.ts";
-import { withTempDir } from "~/vt/git/utils.ts";
+import { doWithTempDir } from "~/vt/git/utils.ts";
 import { assertEquals } from "@std/assert";
 import { verifyProjectStructure } from "~/vt/git/tests/utils.ts";
 import { checkout } from "~/vt/git/checkout.ts";
@@ -26,9 +26,7 @@ for (const testCase of testCases) {
           net: true,
         },
         async fn() {
-          const { tempDir, cleanup } = await withTempDir("vt_checkout_test");
-
-          try {
+          await doWithTempDir(async (tempDir) => {
             // Clone the "from" branch
             await clone({
               targetDir: tempDir,
@@ -58,9 +56,7 @@ for (const testCase of testCases) {
               true,
               `Checkout to branch ${toBranchId} failed structure verification`,
             );
-          } finally {
-            await cleanup();
-          }
+          }, "vt_checkout_test");
         },
       });
     }
@@ -84,21 +80,16 @@ Deno.test({
     });
 
     // Get the main branch ID to fork from
-    const mainBranchId = await branchNameToId(project.id, DEFAULT_BRANCH_NAME);
-    const mainBranch = await sdk.projects.branches.retrieve(
-      project.id,
-      mainBranchId,
-    );
+    const mainBranch = await branchNameToId(project.id, DEFAULT_BRANCH_NAME);
 
     const newBranchName = `test-branch-${crypto.randomUUID()}`;
-    const { tempDir, cleanup } = await withTempDir("vt_checkout_test");
 
-    try {
+    await doWithTempDir(async (tempDir) => {
       // Clone the "from" branch
       await clone({
         targetDir: tempDir,
         projectId: project.id,
-        branchId: mainBranchId,
+        branchId: mainBranch.id,
         version: mainBranch.version,
       });
 
@@ -117,10 +108,9 @@ Deno.test({
       } catch {
         throw new Error("Branch was not created successfully");
       }
-    } finally {
-      await cleanup();
+
       // TODO `await sdk.projects.delete(project.id);` (this API endpoint
       // doesn't exist yet thoguh)
-    }
+    }, "vt_checkout_test");
   },
 });
diff --git a/src/vt/git/tests/clone_test.ts b/src/vt/git/tests/clone_test.ts
index a7e98813..4ff7b449 100644
--- a/src/vt/git/tests/clone_test.ts
+++ b/src/vt/git/tests/clone_test.ts
@@ -1,5 +1,5 @@
 import { clone } from "~/vt/git/clone.ts";
-import { withTempDir } from "~/vt/git/utils.ts";
+import { doWithTempDir } from "~/vt/git/utils.ts";
 import { assertEquals } from "@std/assert";
 import { verifyProjectStructure } from "~/vt/git/tests/utils.ts";
 import { testCases } from "~/vt/git/tests/cases.ts";
@@ -16,9 +16,7 @@ for (const testCase of testCases) {
         net: true,
       },
       async fn() {
-        const { tempDir, cleanup } = await withTempDir("vt_clone_test");
-
-        try {
+        await doWithTempDir(async (tempDir) => {
           // Perform the clone operation
           await clone({
             targetDir: tempDir,
@@ -38,9 +36,7 @@ for (const testCase of testCases) {
             true,
             "Project structure verification failed",
           );
-        } finally {
-          await cleanup();
-        }
+        }, "vt_clone_test");
       },
     });
   }
diff --git a/src/vt/git/tests/pull_test.ts b/src/vt/git/tests/pull_test.ts
index 7e067629..b1f762b7 100644
--- a/src/vt/git/tests/pull_test.ts
+++ b/src/vt/git/tests/pull_test.ts
@@ -1,5 +1,5 @@
 import { clone } from "~/vt/git/clone.ts";
-import { withTempDir } from "~/vt/git/utils.ts";
+import { doWithTempDir } from "~/vt/git/utils.ts";
 import { assertEquals } from "@std/assert";
 import { pull } from "~/vt/git/pull.ts";
 import { verifyProjectStructure } from "~/vt/git/tests/utils.ts";
@@ -17,9 +17,7 @@ for (const testCase of testCases) {
         net: true,
       },
       async fn() {
-        const { tempDir, cleanup } = await withTempDir("vt_pull_test");
-
-        try {
+        await doWithTempDir(async (tempDir) => {
           // First clone with version + 2
           await clone({
             targetDir: tempDir,
@@ -48,9 +46,7 @@ for (const testCase of testCases) {
             true,
             "Project structure verification failed",
           );
-        } finally {
-          await cleanup();
-        }
+        }, "vt_pull_test");
       },
     });
   }
diff --git a/src/vt/git/tests/push_test.ts b/src/vt/git/tests/push_test.ts
new file mode 100644
index 00000000..495b0d3e
--- /dev/null
+++ b/src/vt/git/tests/push_test.ts
@@ -0,0 +1,74 @@
+import { doWithTempDir } from "~/vt/git/utils.ts";
+import { doWithNewProject } from "~/vt/git/tests/utils.ts";
+import sdk from "~/sdk.ts";
+import { push } from "~/vt/git/push.ts";
+import { assertEquals, assertRejects } from "@std/assert";
+import { join } from "@std/path";
+import ValTown from "@valtown/sdk";
+
+Deno.test({
+  name: "test pushing",
+  permissions: {
+    read: true,
+    write: true,
+    net: true,
+  },
+  async fn() {
+    await doWithNewProject(async ({ project, branch }) => {
+      await doWithTempDir(async (tempDir) => {
+        // Create a file and push
+        const vtFilePath = "test.txt";
+        const localFilePath = join(tempDir, vtFilePath);
+        await Deno.writeTextFile(localFilePath, "test");
+        await push({
+          targetDir: tempDir,
+          projectId: project.id,
+          branchId: branch.id,
+          ignoreGlobs: [],
+        });
+
+        // Pull and assert that the creation worked
+        const originalFileContent = await sdk.projects.files
+          .getContent(project.id, vtFilePath)
+          .then((resp) => resp.text());
+        assertEquals(
+          originalFileContent,
+          "test",
+        );
+
+        // Modify the file and push
+        await Deno.writeTextFile(localFilePath, "test2");
+        await push({
+          targetDir: tempDir,
+          projectId: project.id,
+          branchId: branch.id,
+          ignoreGlobs: [],
+        });
+
+        // Pull and assert that the modification worked
+        const newFileContent = await sdk.projects.files
+          .getContent(project.id, vtFilePath)
+          .then((resp) => resp.text());
+        assertEquals(newFileContent, "test2");
+
+        // Delete the file and push
+        await Deno.remove(localFilePath);
+        await push({
+          targetDir: tempDir,
+          projectId: project.id,
+          branchId: branch.id,
+          ignoreGlobs: [],
+        });
+
+        // Assert that the file no longer exists on the remote
+        await assertRejects(
+          async () => {
+            await sdk.projects.files.getContent(project.id, vtFilePath);
+          },
+          ValTown.APIError,
+          "404",
+        );
+      }, "vt_push_test");
+    });
+  },
+});
diff --git a/src/vt/git/tests/status_test.ts b/src/vt/git/tests/status_test.ts
index 96cdb353..bed4b3a8 100644
--- a/src/vt/git/tests/status_test.ts
+++ b/src/vt/git/tests/status_test.ts
@@ -1,6 +1,6 @@
 import { FileStatus, status, StatusResult } from "~/vt/git/status.ts";
 import * as path from "@std/path";
-import { withTempDir } from "~/vt/git/utils.ts";
+import { doWithTempDir } from "~/vt/git/utils.ts";
 import { clone } from "~/vt/git/clone.ts";
 import { assert } from "@std/assert";
 import { TestCaseBranchData, testCases } from "~/vt/git/tests/cases.ts";
@@ -17,9 +17,7 @@ for (const testCase of testCases) {
         net: true,
       },
       async fn() {
-        const { tempDir, cleanup } = await withTempDir("vt_status_test");
-
-        try {
+        await doWithTempDir(async (tempDir) => {
           // Clone the project
           await clone({
             targetDir: tempDir,
@@ -73,9 +71,7 @@ for (const testCase of testCases) {
 
           // Validate status remains unchanged
           validateStatus(result, branchData);
-        } finally {
-          await cleanup();
-        }
+        }, "vt_status_test");
       },
     });
   }
diff --git a/src/vt/git/tests/utils.ts b/src/vt/git/tests/utils.ts
index 095ce735..4f937e4c 100644
--- a/src/vt/git/tests/utils.ts
+++ b/src/vt/git/tests/utils.ts
@@ -1,5 +1,6 @@
 import { join } from "@std/path";
 import { walk } from "@std/fs";
+import sdk, { branchNameToId } from "~/sdk.ts";
 
 export interface ExpectedProjectInode {
   path: string;
@@ -102,3 +103,36 @@ export async function verifyProjectStructure(
 
   return true;
 }
+
+/**
+ * Creates a temporary project and executes an operation with it.
+ * Provides project and branch information to the operation callback.
+ *
+ * @param op Function that takes a project, branch, and returns a Promise
+ * @returns Promise that resolves to the result of the operation
+ */
+export async function doWithNewProject<T>(
+  op: (
+    projectInfo: {
+      project: { id: string; name: string };
+      branch: { id: string; version: number };
+    },
+  ) => Promise<T>,
+): Promise<T> {
+  // Create a blank project with a random name
+  const project = await sdk.projects.create({
+    name: crypto.randomUUID().slice(10),
+    description: "This is a test project",
+    privacy: "public",
+  });
+
+  // Get the main branch ID
+  const branch = await branchNameToId(project.id, "main");
+
+  try {
+    // Execute the provided operation with project info
+    return await op({ project, branch });
+  } finally {
+    await sdk.projects.delete(project.id);
+  }
+}
diff --git a/src/vt/git/utils.ts b/src/vt/git/utils.ts
index 868aa3a0..15d62cfb 100644
--- a/src/vt/git/utils.ts
+++ b/src/vt/git/utils.ts
@@ -1,6 +1,8 @@
 import { StatusResult } from "~/vt/git/status.ts";
 import * as path from "@std/path";
-import { shouldIgnoreGlob } from "~/vt/git/paths.ts";
+import { shouldIgnore } from "~/vt/git/paths.ts";
+import ValTown from "@valtown/sdk";
+import sdk from "~/sdk.ts";
 import { copy, ensureDir } from "@std/fs";
 
 /**
@@ -26,6 +28,26 @@ export async function withTempDir(
   };
 }
 
+/**
+ * Executes an operation in a temporary directory and ensures cleanup.
+ *
+ * @param op Function that takes a temporary directory path and returns a Promise
+ * @param tmpLabel Optional prefix for the temporary directory name
+ * @returns Promise that resolves to the result of the operation
+ */
+export async function doWithTempDir<T>(
+  op: (tmpDir: string) => Promise<T>,
+  tmpLabel?: string,
+): Promise<T> {
+  const { tempDir, cleanup } = await withTempDir(tmpLabel);
+
+  try {
+    return await op(tempDir);
+  } finally {
+    await cleanup();
+  }
+}
+
 /**
  * Create a directory atomically by first doing logic to create it in a temp
  * directory, and then moving it to a destination afterwards.
@@ -64,7 +86,7 @@ export async function cleanDirectory(
   ignoreGlobs: string[],
 ): Promise<void> {
   const filesToRemove = Deno.readDirSync(directory)
-    .filter((entry) => !shouldIgnoreGlob(entry.name, ignoreGlobs))
+    .filter((entry) => !shouldIgnore(entry.name, ignoreGlobs))
     .map((entry) => ({
       path: path.join(directory, entry.name),
       isDirectory: entry.isDirectory,
@@ -85,3 +107,57 @@ export async function cleanDirectory(
 export function isDirty(statusResult: StatusResult): boolean {
   return statusResult.modified.length > 0;
 }
+
+/**
+ * Ensures that a directory path exists within a project in Valtown.
+ * This function creates each directory in the specified path if it doesn't already exist.
+ *
+ * @param {string} projectId - The ID of the project where the directory should be ensured.
+ * @param {string} branchId - The ID of the branch where the directory should be created.
+ * @param {string} filePath - The file path for which directories need to be ensured.
+ * @returns {Promise<void>} A promise that resolves when all necessary directories are created.
+ */
+export async function ensureValtownDir(
+  projectId: string,
+  branchId: string,
+  filePath: string,
+): Promise<void> {
+  const dirPath = path.dirname(filePath);
+
+  // If path is "." (current directory) or empty, no directories need to be created
+  if (dirPath === "." || dirPath === "") {
+    return;
+  }
+
+  // Split the path into segments
+  const segments = dirPath.split("/");
+  let currentPath = "";
+
+  // Create each directory in the path if it doesn't exist
+  for (let i = 0; i < segments.length; i++) {
+    if (segments[i] === "") continue;
+
+    currentPath += (currentPath ? "/" : "") + segments[i];
+
+    // Create directory - content can be null, empty string, or omitted for directories
+    try {
+      await sdk.projects.files.create(
+        projectId,
+        encodeURIComponent(currentPath),
+        {
+          type: "directory",
+          branch_id: branchId,
+          content: null,
+        },
+      );
+    } catch (error) {
+      if (error instanceof ValTown.APIError) {
+        if (error.status != 409) {
+          throw error;
+        }
+      } else {
+        throw error;
+      }
+    }
+  }
+}
diff --git a/src/vt/vt/VTClient.ts b/src/vt/vt/VTClient.ts
index beaec9bb..9ec602eb 100644
--- a/src/vt/vt/VTClient.ts
+++ b/src/vt/vt/VTClient.ts
@@ -3,9 +3,12 @@ import { DEFAULT_BRANCH_NAME, DEFAULT_IGNORE_PATTERNS } from "~/consts.ts";
 import sdk, { branchNameToId, getLatestVersion } from "~/sdk.ts";
 import VTMeta from "~/vt/vt/VTMeta.ts";
 import { pull } from "~/vt/git/pull.ts";
+import { push } from "~/vt/git/push.ts";
 import { status, StatusResult } from "~/vt/git/status.ts";
+import { debounce } from "jsr:@std/async/debounce";
 import { checkout } from "~/vt/git/checkout.ts";
 import { isDirty } from "~/vt/git/utils.ts";
+import ValTown from "@valtown/sdk";
 
 /**
  * The VTClient class is an abstraction on a VT directory that exposes
@@ -48,11 +51,11 @@ export default class VTClient {
    * Initialize the VT instance for a project. You always have to be checked
    * out to *something* so init also takes an initial branch.
    *
-   * @param {string} rootPath - The root path where the VT instance will be initialized
-   * @param {string} username - The username of the project owner
-   * @param {string} projectName - The name of the project
-   * @param {number} [version=-1] - The version of the project to initialize. -1 for latest version
-   * @param {string} [branchName=DEFAULT_BRANCH_NAME] - The branch name to initialize
+   * @param {string} rootPath The root path where the VT instance will be initialized
+   * @param {string} username The username of the project owner
+   * @param {string} projectName The name of the project
+   * @param {number} version The version of the project to initialize. -1 for latest version
+   * @param {string} branchName The branch name to initialize
    * @returns {Promise<VTClient>} A new VTClient instance
    */
   public static async init(
@@ -71,13 +74,13 @@ export default class VTClient {
         throw new Error("Project not found");
       });
 
-    const branchId = await branchNameToId(projectId, branchName);
+    const branch = await branchNameToId(projectId, branchName);
 
     // If they choose -1 as the version then change to use the most recent
     // version
     if (version == -1) {
       version =
-        (await sdk.projects.branches.retrieve(projectId, branchId)).version;
+        (await sdk.projects.branches.retrieve(projectId, branch.id)).version;
     }
 
     const vt = new VTClient(rootPath);
@@ -88,7 +91,7 @@ export default class VTClient {
       if (error instanceof Deno.errors.NotFound) {
         await vt.getMeta().saveConfig({
           projectId,
-          currentBranch: branchId,
+          currentBranch: branch.id,
           version: version,
         });
       } else {
@@ -104,13 +107,74 @@ export default class VTClient {
    * directory. Loads the configuration from the `.vt` folder in the given
    * directory.
    *
-   * @param {string} rootPath - The root path of the existing project.
+   * @param {string} rootPath The root path of the existing project.
    * @returns {Promise<VTClient>} An instance of VTClient initialized from existing config.
    */
   public static from(rootPath: string): VTClient {
     return new VTClient(rootPath);
   }
 
+  /**
+   * Watch the root directory for changes and automatically push to Val Town
+   * when files are updated locally.
+   *
+   * If another instance of the program is already running then this errors. A lock file with
+   * the running program's PID is maintained automatically.
+   *
+   * @returns {Promise<never>} A promise that never resolves, representing the ongoing watch process.
+   */
+  public async watch() {
+    // Set the lock file at the start
+    await this.meta.setLockFile();
+
+    // Listen for termination signals to perform cleanup
+    for (const signal of ["SIGINT", "SIGTERM"]) {
+      Deno.addSignalListener(signal as Deno.Signal, () => {
+        console.log("Stopping watch process...");
+        this.meta.rmLockFile();
+        Deno.exit(0);
+      });
+    }
+
+    // A function that runs a push on file system changes
+    const pushOnFileEvents = async () => {
+      const debouncedPush = debounce(async (_event: Deno.FsEvent) => {
+        try {
+          await this.push(this.rootPath);
+        } catch (e) {
+          // Handle case where the file was deleted before we could push it
+          if (e instanceof Deno.errors.NotFound) {
+            // The file no longer exists at the time of uploading. It could've
+            // just been a temporary file, but since it no longer exists it
+            // isn't our problem.
+            return;
+          }
+
+          // Handle case where the API returns a 404 Not Found error
+          if (e instanceof ValTown.APIError && e.status === 404) {
+            // The val we're trying to update doesn't exist on the server. This
+            // is usually a result of starting a deletion and then trying to
+            // delete a second time because of duplicate file system events.
+            //
+            // TODO: We should keep a global queue of outgoing requests and
+            // intelligently notice that we have duplicate idempotent (in this
+            // case deletions are) requests in the queue.
+            return;
+          }
+
+          // Re-throw any other errors
+          throw e;
+        }
+      }, 300);
+
+      const watcher = Deno.watchFs(this.rootPath);
+      for await (const event of watcher) debouncedPush(event);
+    };
+
+    // Since we're only pushing now, we just need to return the pushOnFileEvents promise
+    return pushOnFileEvents();
+  }
+
   /**
    * Create a new Val Town project and initialize a VT instance for it.
    *
@@ -136,10 +200,7 @@ export default class VTClient {
     });
 
     // Get the project branch
-    const branch = await sdk.projects.branches.retrieve(
-      project.id,
-      await branchNameToId(project.id, DEFAULT_BRANCH_NAME),
-    );
+    const branch = await branchNameToId(project.id, DEFAULT_BRANCH_NAME);
 
     // Then clone it to the target directory
     await clone({
@@ -168,10 +229,6 @@ export default class VTClient {
   public async clone(targetDir: string): Promise<void> {
     const { projectId, currentBranch, version } = await this.meta.loadConfig();
 
-    if (!projectId || !currentBranch || version === null) {
-      throw new Error("Configuration not loaded");
-    }
-
     // Do the clone using the configuration
     await clone({
       targetDir,
@@ -191,20 +248,23 @@ export default class VTClient {
    * @returns {Promise<void>}
    */
   public async pull(targetDir: string): Promise<void> {
-    const { projectId, currentBranch } = await this.meta.loadConfig();
+    const config = await this.meta.loadConfig();
 
-    if (!projectId || !currentBranch) {
-      throw new Error("Configuration not loaded");
-    }
+    config.version = await getLatestVersion(
+      config.projectId,
+      config.currentBranch,
+    );
 
     // Use the provided pull function
     await pull({
       targetDir,
-      projectId,
-      branchId: currentBranch,
-      version: await getLatestVersion(projectId, currentBranch),
+      projectId: config.projectId,
+      branchId: config.currentBranch,
+      version: config.version,
       ignoreGlobs: await this.getIgnoreGlobs(),
     });
+
+    await this.meta.saveConfig(config);
   }
 
   /**
@@ -217,15 +277,32 @@ export default class VTClient {
   public async status(targetDir: string): Promise<StatusResult> {
     const { projectId, currentBranch } = await this.meta.loadConfig();
 
-    if (!projectId || !currentBranch) {
+    return status({
+      targetDir,
+      projectId,
+      branchId: currentBranch,
+      version: await getLatestVersion(projectId, currentBranch),
+      ignoreGlobs: await this.getIgnoreGlobs(),
+    });
+  }
+
+  /**
+   * Push changes from the local directory to the Val Town project.
+   *
+   * @param {string} targetDir - The directory containing local changes to push.
+   * @returns {Promise<void>}
+   */
+  public async push(targetDir: string): Promise<void> {
+    const { projectId, currentBranch, version } = await this.meta.loadConfig();
+
+    if (!projectId || !currentBranch || version === null) {
       throw new Error("Configuration not loaded");
     }
 
-    return status({
+    await push({
       targetDir,
       projectId,
       branchId: currentBranch,
-      version: await getLatestVersion(projectId, currentBranch),
       ignoreGlobs: await this.getIgnoreGlobs(),
     });
   }
@@ -251,10 +328,10 @@ export default class VTClient {
     // them to the newest version of a branch when they check out a new branch.
     // This is a bit different than git, but it follows our notion of "no local
     // state, val town is the source of truth")
-    const checkoutBranchId = await branchNameToId(config.projectId, branchName);
+    const checkoutBranch = await branchNameToId(config.projectId, branchName);
     const latestVersion = await getLatestVersion(
       config.projectId,
-      checkoutBranchId,
+      checkoutBranch.id,
     );
 
     const created =
@@ -283,7 +360,7 @@ export default class VTClient {
       await checkout({
         targetDir,
         projectId: config.projectId,
-        branchId: checkoutBranchId,
+        branchId: checkoutBranch.id,
         ignoreGlobs,
         version: latestVersion,
       });
diff --git a/src/vt/vt/VTMeta.ts b/src/vt/vt/VTMeta.ts
index facb61c0..031a3e46 100644
--- a/src/vt/vt/VTMeta.ts
+++ b/src/vt/vt/VTMeta.ts
@@ -1,6 +1,10 @@
 import z from "zod";
 import { VTMetaConfigJsonSchema } from "~/vt/vt/schemas.ts";
-import { CONFIG_FILE_NAME, META_FOLDER_NAME } from "~/consts.ts";
+import {
+  CONFIG_FILE_NAME,
+  META_FOLDER_NAME,
+  META_LOCK_FILE_NAME,
+} from "~/consts.ts";
 import * as path from "@std/path";
 
 /**
@@ -28,6 +32,18 @@ export default class VTMeta {
     return path.join(this.#rootPath, META_FOLDER_NAME, CONFIG_FILE_NAME);
   }
 
+  /**
+   * Gets the full path to the (maybe present) lock file.
+   *
+   * The lock file contains the PID of a running (watching) vt process, if
+   * there is one. Otherwise no file will exist at this path.
+   *
+   * @return {string} The VT lock file path.
+   */
+  public lockFilePath(): string {
+    return path.join(this.#rootPath, META_FOLDER_NAME, META_LOCK_FILE_NAME);
+  }
+
   /**
    * Reads and parses the configuration file.
    *
@@ -100,4 +116,46 @@ export default class VTMeta {
 
     return ignoreGlobs;
   }
+
+  /**
+   * Create a lock file with a PID of the VT process watching the cloned active
+   * directory.
+   *
+   * @return {Promise<string|null>} A promise resolving to the PID of the running watcher, or null if no watchers are running
+   */
+  public async getLockFile(): Promise<string | null> {
+    try {
+      return await Deno.readTextFile(this.lockFilePath());
+    } catch (error) {
+      if (error instanceof Deno.errors.NotFound) return null;
+      else throw error;
+    }
+  }
+
+  /**
+   * Set a lock file with the PID of the VT process watching the cloned active
+   * directory.
+   */
+  public async setLockFile() {
+    // Make sure the program isn't already running
+    const runningPid = await this.getLockFile();
+    try {
+      if (runningPid !== null) Deno.kill(parseInt(runningPid));
+    } catch (e) {
+      if (e instanceof Deno.errors.NotFound) {
+        // We couldn't kill the process, but that just means that the lock file
+        // probably was not up to date and had an already-dead process in it.
+        // We can ignore this error.
+      } else throw e;
+    }
+
+    await Deno.writeTextFile(this.lockFilePath(), Deno.pid.toString());
+  }
+
+  /**
+   * Delete the vt lock file.
+   */
+  public async rmLockFile() {
+    await Deno.remove(this.lockFilePath());
+  }
 }
diff --git a/vt.ts b/vt.ts
index fe127797..94a1605d 100755
--- a/vt.ts
+++ b/vt.ts
@@ -6,3 +6,43 @@ export default vt;
 if (import.meta.main) {
   await vt.parse(Deno.args);
 }
+
+/**
+ * Returns the full command-line invocation as an array.
+ * This includes the path to the executable and any script/module URL,
+ * followed by the arguments passed to the program.
+ *
+ * @returns {string[]} An array containing the executable path, script/module URL, and arguments.
+ */
+export function runCmd(args: string[]): [string, string[]] {
+  const executablePath = Deno.execPath();
+  const scriptModule = Deno.mainModule;
+
+  // Check if running as a compiled executable
+  const isCompiled = executablePath === scriptModule;
+
+  if (isCompiled) {
+    // Running as a compiled executable
+    // You don't need to explicitly provide the deno executable the permission
+    // flags since ideally it is baked in at compile time.
+    return [executablePath, args];
+  } else {
+    // Running with `deno run`, include deno executable and necessary flags
+    const scriptPath = new URL(scriptModule).pathname;
+
+    // When running with deno you need to explicitly provide all the required
+    // flags
+    return [
+      executablePath,
+      [
+        "run",
+        "--allow-read",
+        "--allow-write",
+        "--allow-env",
+        "--allow-net",
+        scriptPath,
+        ...args,
+      ],
+    ];
+  }
+}


Reviews:
user: login: 404Wolf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:39:07Z
user: login: 404Wolf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:41:05Z
user: login: 404Wolf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:42:05Z
user: login: 404Wolf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:42:28Z
user: login: 404Wolf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:42:59Z
user: login: 404Wolf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:44:40Z
user: login: 404Wolf
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T01:46:12Z
user: login: maxmcd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T15:12:55Z
user: login: maxmcd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T15:22:45Z

Review comments:
diff_hunk: @@ -0,0 +1,77 @@
+import { Command } from "@cliffy/command";
+import Kia from "kia";
+import VTClient from "~/vt/vt/VTClient.ts";
+import { runCmd } from "../../vt.ts";
+import { spawn } from "node:child_process";
+import process from "node:process";
+
+export const watchStopCmd = new Command()
+  .name("watch stop")
+  .description("Stop the watch daemon process")
+  .action(async () => {
+    const cwd = process.cwd();
+    const vt = VTClient.from(cwd);
+    const kia = new Kia("Stopping the watch process...");
+    kia.start();
+
+    try {
+      const pidStr = await vt.meta.getLockFile();
+      if (pidStr) {
+        const pid = parseInt(pidStr, 10);
+        if (!isNaN(pid)) {
+          process.kill(pid);
+          kia.succeed(`Stopped watch process with PID: ${pid}`);
+        } else {
+          kia.fail("Invalid PID in lockfile.");
+        }
+      } else {
+        kia.fail("No running watch process found.");
+      }
+    } catch (error) {
+      kia.fail("Failed to stop the watch process.");
+      console.error(error);
+    }
+  });
+
+export const watchCmd = new Command()
+  .name("watch")
+  .description("Watch for changes and automatically sync with Val Town")
+  .option("--daemon [daemon:boolean]", "Run as daemon process", {
+    default: false,
+  })
+  .action(async ({ daemon }) => {
+    const cwd = process.cwd();
+
+    if (daemon) {
+      const [binary, args] = runCmd(["watch"]);
+
+      // Spawn a detached child process
+      const child = spawn(binary, args, {
path: src/cmd/watch.ts
user: login: 404Wolf
body: Ughh node in deno 🤮. Pure deno + deno subproc would be great, but you can't detach a process with the Deno Command api. https://github.com/denoland/deno/issues/5501. I guess deno node subproc comat it is :/
created_at: 2025-03-12T01:39:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,12 +24,12 @@ export const STATUS_COLORS: Record<
   not_modified: { prefix: " ", color: colors.gray },
 };
 
-export const VAL_TYPE_EXTENSIONS: Record<
-  string,
-  { abbreviated: string; standard: string }
-> = {
-  "script": { abbreviated: "S", standard: "script" },
-  "http": { abbreviated: "H", standard: "http" },
-  "email": { abbreviated: "E", standard: "email" },
-  "interval": { abbreviated: "C", standard: "cron" },
-};
+export const DEFAULT_VAL_TYPE = "script";
+
+export type ProjectItem =
path: src/consts.ts
user: login: 404Wolf
body: Might be a way to access with stainless TODO: @404Wolf 
created_at: 2025-03-12T01:41:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,12 +17,44 @@ const sdk = new ValTown({
 async function branchNameToId(
   projectId: string,
   branchName: string,
-): Promise<string> {
+): Promise<ValTown.Projects.Branches.BranchListResponse> {
   for await (const branch of sdk.projects.branches.list(projectId, {})) {
-    if (branch.name == branchName) return branch.id;
+    if (branch.name == branchName) return branch;
   }
 
-  throw new Error(`Branch "${branchName}" not found in project ${projectId}`);
+  throw new Deno.errors.NotFound(
+    `Branch "${branchName}" not found in project ${projectId}`,
+  );
+}
+
+/**
+ * Converts a file path to its corresponding project file for a given project.
+ *
+ * @param {string} projectId The ID of the project containing the file
+ * @param {string} branchId The ID of the project branch to reference
+ * @param {number} version The version of the project for the file being found
+ * @param {string} filePath The file path to locate
+ * @returns {Promise} Promise resolving to the branch ID
+ * @throws {Error} if the branch is not found or if the API request fails
+ */
+async function filePathToFile(
path: src/sdk.ts
user: login: 404Wolf
body: Should we add an alias API for file paths? Maybe?
created_at: 2025-03-12T01:42:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,12 +17,44 @@ const sdk = new ValTown({
 async function branchNameToId(
   projectId: string,
   branchName: string,
-): Promise<string> {
+): Promise<ValTown.Projects.Branches.BranchListResponse> {
   for await (const branch of sdk.projects.branches.list(projectId, {})) {
-    if (branch.name == branchName) return branch.id;
+    if (branch.name == branchName) return branch;
   }
 
-  throw new Error(`Branch "${branchName}" not found in project ${projectId}`);
+  throw new Deno.errors.NotFound(
+    `Branch "${branchName}" not found in project ${projectId}`,
+  );
+}
+
+/**
+ * Converts a file path to its corresponding project file for a given project.
+ *
+ * @param {string} projectId The ID of the project containing the file
+ * @param {string} branchId The ID of the project branch to reference
+ * @param {number} version The version of the project for the file being found
+ * @param {string} filePath The file path to locate
+ * @returns {Promise} Promise resolving to the branch ID
+ * @throws {Error} if the branch is not found or if the API request fails
+ */
+async function filePathToFile(
path: src/sdk.ts
user: login: 404Wolf
body: I could make this more dry but I don't think it matters
created_at: 2025-03-12T01:42:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,48 +1,68 @@
 import * as path from "@std/path";
-import { VAL_TYPE_EXTENSIONS } from "~/consts.ts";
+import { DEFAULT_VAL_TYPE, ProjectItem } from "~/consts.ts";
+import { filePathToFile } from "~/sdk.ts";
 
 /**
- * Adds val file extension to a filename
+ * Determine the type of a project file.
  *
- * @param filename Base filename
- * @param type Val file type (script, http, ...)
- * @param abbreviated Whether to use val file extension (default: false)
path: src/vt/git/paths.ts
user: login: 404Wolf
body: Fancy project file name detection algorithm :)
created_at: 2025-03-12T01:42:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,66 @@
+import { clone } from "~/vt/git/clone.ts";
+import { withTempDir } from "~/vt/git/utils.ts";
+import sdk, { branchNameToId } from "~/sdk.ts";
+import { push } from "~/vt/git/push.ts";
+import { assertEquals } from "@std/assert";
+import { join } from "@std/path";
+
+Deno.test({
+  name: "test pushing",
+  permissions: {
+    read: true,
+    write: true,
+    net: true,
+  },
+  async fn() {
+    const { tempDir, cleanup } = await withTempDir("vt_push_test");
+
+    try {
+      // Create a blank project
path: src/vt/git/tests/push_test.ts
user: login: 404Wolf
body: I think this is how the tests should all be rewritten. Create a new blank project, do stuff, assert stuff, and delete the project when done (no endpoint for that yet though :/)
created_at: 2025-03-12T01:44:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,39 @@ export default vt;
 if (import.meta.main) {
   await vt.parse(Deno.args);
 }
+
+/**
path: vt.ts
user: login: 404Wolf
body: Maybe you know a better way to do this: figuring out what the command is to execute the program. If it's a compiled deno program then the way to execute in the same way that the user originally executed is slightly different then if the user used deno. If not this is fine I guess.
created_at: 2025-03-12T01:46:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+import { Command } from "@cliffy/command";
+import Kia from "kia";
+import VTClient from "~/vt/vt/VTClient.ts";
+import { runCmd } from "../../vt.ts";
+import { spawn } from "node:child_process";
+import process from "node:process";
+
+export const watchStopCmd = new Command()
+  .name("watch stop")
+  .description("Stop the watch daemon process")
+  .action(async () => {
+    const cwd = process.cwd();
+    const vt = VTClient.from(cwd);
+    const kia = new Kia("Stopping the watch process...");
+    kia.start();
+
+    try {
+      const pidStr = await vt.meta.getLockFile();
+      if (pidStr) {
+        const pid = parseInt(pidStr, 10);
+        if (!isNaN(pid)) {
+          process.kill(pid);
+          kia.succeed(`Stopped watch process with PID: ${pid}`);
+        } else {
+          kia.fail("Invalid PID in lockfile.");
+        }
+      } else {
+        kia.fail("No running watch process found.");
+      }
+    } catch (error) {
+      kia.fail("Failed to stop the watch process.");
+      console.error(error);
+    }
+  });
+
+export const watchCmd = new Command()
+  .name("watch")
+  .description("Watch for changes and automatically sync with Val Town")
+  .option("--daemon [daemon:boolean]", "Run as daemon process", {
+    default: false,
+  })
+  .action(async ({ daemon }) => {
+    const cwd = process.cwd();
+
+    if (daemon) {
+      const [binary, args] = runCmd(["watch"]);
+
+      // Spawn a detached child process
+      const child = spawn(binary, args, {
path: src/cmd/watch.ts
user: login: maxmcd
body: I think we should really avoid doing this for now, or maybe ever. I would be worried about people losing track of a process that is writing to their account. Having watch run in an open terminal keeps with current expectations of existing tools and if people really want this we can explore what suits their needs.

Also personally have many gripes about dealing with lost processes in nodejs and maybe particularly feeling the "avoid this" vibes here. 
created_at: 2025-03-12T15:12:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,3 +6,39 @@ export default vt;
 if (import.meta.main) {
   await vt.parse(Deno.args);
 }
+
+/**
path: vt.ts
user: login: maxmcd
body: I think another reason to avoid the daemon mode? Lots of trickiness here. I don't think an inherent issue, but I also don't love how this complicates the security story. 

This is generally how I think I would do it though, execPath and then fixup the args. 
created_at: 2025-03-12T15:22:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: 404Wolf
created_at: 2025-03-12T01:28:21Z
body: Solves https://github.com/val-town/vt/issues/13!
author_association: CONTRIBUTOR
user: login: 404Wolf
created_at: 2025-03-12T13:32:51Z
body: TODO: bug with temp files created by ide (specifically neovim ~ files). Need to look into this further.  
author_association: CONTRIBUTOR