diff --git a/.icons/folder.svg b/.icons/folder.svg
new file mode 100644
index 000000000..b718dea59
--- /dev/null
+++ b/.icons/folder.svg
@@ -0,0 +1 @@
+
diff --git a/registry/coder-labs/modules/archive/README.md b/registry/coder-labs/modules/archive/README.md
new file mode 100644
index 000000000..0c7e4ff7f
--- /dev/null
+++ b/registry/coder-labs/modules/archive/README.md
@@ -0,0 +1,163 @@
+---
+display_name: Archive
+description: Create automated and user-invocable scripts that archive and extract selected files/directories with optional compression (gzip or zstd).
+icon: ../../../../.icons/folder.svg
+verified: false
+tags: [backup, archive, tar, helper]
+---
+
+# Archive
+
+This module installs small, robust scripts in your workspace to create and extract tar archives from a list of files and directories. It supports optional compression (gzip or zstd). The create command prints only the resulting archive path to stdout; operational logs go to stderr. An optional stop hook can also create an archive automatically when the workspace stops, and an optional start hook can wait for an archive on-disk and extract it on start.
+
+```tf
+module "archive" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder-labs/archive/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.example.id
+
+ paths = ["./projects", "./code"]
+}
+```
+
+## Features
+
+- Installs two commands into the workspace `$PATH`: `coder-archive-create` and `coder-archive-extract`.
+- Creates a single `.tar`, `.tar.gz`, or `.tar.zst` containing selected paths (depends on `tar`).
+- Optional compression: `gzip`, `zstd` (depends on `gzip` or `zstd`).
+- Stores defaults so commands can be run without arguments (supports overriding via CLI flags).
+- Logs and status messages go to stderr, the create command prints only the final archive path to stdout.
+- Optional:
+ - `create_on_stop` to create an archive automatically when the workspace stops.
+ - `extract_on_start` to wait for an archive to appear and extract it on start.
+
+> [!WARNING]
+> The `create_on_stop` feature uses the `coder_script` `run_on_stop` which may not work as expected on certain templates without additional provider configuration. The agent may be terminated before the script completes. See [coder/coder#6174](https://github.com/coder/coder/issues/6174) for provider-specific workarounds and [coder/coder#6175](https://github.com/coder/coder/issues/6175) for tracking a fix.
+
+## Usage
+
+Basic example:
+
+```tf
+module "archive" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder-labs/archive/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.example.id
+
+ # Paths to include in the archive (files or directories).
+ directory = "~"
+ paths = [
+ "./projects",
+ "./code",
+ ]
+}
+```
+
+Customize compression and output:
+
+```tf
+module "archive" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder-labs/archive/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.example.id
+
+ directory = "/"
+ paths = ["/etc", "/home"]
+ compression = "zstd" # "gzip" | "zstd" | "none"
+ output_dir = "/tmp/backup" # defaults to /tmp
+ archive_name = "my-backup" # base name (extension is inferred from compression)
+}
+```
+
+Enable auto-archive on stop:
+
+```tf
+module "archive" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder-labs/archive/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.example.id
+
+ # Creates /tmp/coder-archive.tar.gz of the users home directory (defaults).
+ create_on_stop = true
+}
+```
+
+Extract on start:
+
+```tf
+module "archive" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder-labs/archive/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.example.id
+
+ # Where to look for the archive file to extract:
+ output_dir = "/tmp"
+ archive_name = "my-archive"
+ compression = "gzip"
+
+ # Waits up to 5 minutes for /tmp/my-archive.tar.gz to be present, note that
+ # using a long timeout will delay every workspace start by this much until the
+ # archive is present.
+ extract_on_start = true
+ extract_wait_timeout_seconds = 300
+}
+```
+
+## Command usage
+
+The installer writes the following files:
+
+- `$CODER_SCRIPT_DATA_DIR/archive-lib.sh`
+- `$CODER_SCRIPT_BIN_DIR/coder-archive-create`
+- `$CODER_SCRIPT_BIN_DIR/coder-archive-extract`
+
+Create usage:
+
+```console
+coder-archive-create [OPTIONS] [PATHS...]
+ -c, --compression Compression algorithm (default from module)
+ -C, --directory Change to directory for archiving (default from module)
+ -f, --file Output archive file (default from module)
+ -h, --help Show help
+```
+
+Extract usage:
+
+```console
+coder-archive-extract [OPTIONS]
+ -c, --compression Compression algorithm (default from module)
+ -C, --directory Extract into directory (default from module)
+ -f, --file Archive file to extract (default from module)
+ -h, --help Show help
+```
+
+Examples:
+
+- Use Terraform defaults:
+
+ ```
+ coder-archive-create
+ ```
+
+- Override compression and output file at runtime:
+
+ ```
+ coder-archive-create --compression zstd --file /tmp/backups/archive.tar.zst
+ ```
+
+- Add extra paths on the fly (in addition to the Terraform defaults):
+
+ ```
+ coder-archive-create /etc/hosts
+ ```
+
+- Extract an archive into a directory:
+
+ ```
+ coder-archive-extract --file /tmp/backups/archive.tar.gz --directory /tmp/restore
+ ```
diff --git a/registry/coder-labs/modules/archive/archive.tftest.hcl b/registry/coder-labs/modules/archive/archive.tftest.hcl
new file mode 100644
index 000000000..944ddbb18
--- /dev/null
+++ b/registry/coder-labs/modules/archive/archive.tftest.hcl
@@ -0,0 +1,33 @@
+mock_provider "coder" {}
+
+run "apply_defaults" {
+ command = apply
+
+ variables {
+ agent_id = "agent-123"
+ paths = ["~/project", "/etc/hosts"]
+ }
+
+ assert {
+ condition = output.archive_path == "/tmp/coder-archive.tar.gz"
+ error_message = "archive_path should be empty when archive_name is not set"
+ }
+}
+
+run "apply_with_name" {
+ command = apply
+
+ variables {
+ agent_id = "agent-123"
+ paths = ["/etc/hosts"]
+ archive_name = "nightly"
+ output_dir = "/tmp/backups"
+ compression = "zstd"
+ create_archive_on_stop = true
+ }
+
+ assert {
+ condition = output.archive_path == "/tmp/backups/nightly.tar.zst"
+ error_message = "archive_path should be computed from archive_name + output_dir + extension"
+ }
+}
diff --git a/registry/coder-labs/modules/archive/main.test.ts b/registry/coder-labs/modules/archive/main.test.ts
new file mode 100644
index 000000000..3f51ff033
--- /dev/null
+++ b/registry/coder-labs/modules/archive/main.test.ts
@@ -0,0 +1,348 @@
+import { describe, expect, it, beforeAll } from "bun:test";
+import {
+ execContainer,
+ findResourceInstance,
+ runContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+ type TerraformState,
+} from "~test";
+
+const USE_XTRACE =
+ process.env.ARCHIVE_TEST_XTRACE === "1" || process.env.XTRACE === "1";
+
+const IMAGE = "alpine";
+const BIN_DIR = "/tmp/coder-script-data/bin";
+const DATA_DIR = "/tmp/coder-script-data";
+
+type ExecResult = {
+ exitCode: number;
+ stdout: string;
+ stderr: string;
+};
+
+const ensureRunOk = (label: string, res: ExecResult) => {
+ if (res.exitCode !== 0) {
+ console.error(
+ `[${label}] non-zero exit code: ${res.exitCode}\n--- stdout ---\n${res.stdout.trim()}\n--- stderr ---\n${res.stderr.trim()}\n--------------`,
+ );
+ }
+ expect(res.exitCode).toBe(0);
+};
+
+const sh = async (id: string, cmd: string): Promise => {
+ const res = await execContainer(id, ["sh", "-c", cmd]);
+ return res;
+};
+
+const bashRun = async (id: string, cmd: string): Promise => {
+ const injected = USE_XTRACE ? `/bin/bash -x ${cmd}` : cmd;
+ return sh(id, injected);
+};
+
+const prepareContainer = async (image = IMAGE) => {
+ const id = await runContainer(image);
+ // Prepare script dirs and deps.
+ ensureRunOk(
+ "mkdirs",
+ await sh(id, `mkdir -p ${BIN_DIR} ${DATA_DIR} /tmp/backup`),
+ );
+
+ // Install tools used by tests.
+ ensureRunOk(
+ "apk add",
+ await sh(id, "apk add --no-cache bash tar gzip zstd coreutils"),
+ );
+
+ return id;
+};
+
+const installArchive = async (
+ state: TerraformState,
+ opts?: { env?: string[] },
+) => {
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await prepareContainer();
+ // Run installer script with correct env for CODER_SCRIPT paths.
+ const args = ["bash"];
+ if (USE_XTRACE) args.push("-x");
+ args.push("-c", instance.script);
+
+ const resp = await execContainer(id, args, [
+ "--env",
+ `CODER_SCRIPT_BIN_DIR=${BIN_DIR}`,
+ "--env",
+ `CODER_SCRIPT_DATA_DIR=${DATA_DIR}`,
+ ...(opts?.env ?? []),
+ ]);
+
+ return {
+ id,
+ install: {
+ exitCode: resp.exitCode,
+ stdout: resp.stdout.trim(),
+ stderr: resp.stderr.trim(),
+ },
+ };
+};
+
+const fileExists = async (id: string, path: string) => {
+ const res = await sh(id, `test -f ${path} && echo yes || echo no`);
+ return res.stdout.trim() === "yes";
+};
+
+const isExecutable = async (id: string, path: string) => {
+ const res = await sh(id, `test -x ${path} && echo yes || echo no`);
+ return res.stdout.trim() === "yes";
+};
+
+const listTar = async (id: string, path: string) => {
+ // Try to autodetect compression flags from extension.
+ let cmd = "";
+ if (path.endsWith(".tar.gz")) {
+ cmd = `tar -tzf ${path}`;
+ } else if (path.endsWith(".tar.zst")) {
+ // validate with zstd and ask tar to list via --zstd.
+ cmd = `zstd -t -q ${path} && tar --zstd -tf ${path}`;
+ } else {
+ cmd = `tar -tf ${path}`;
+ }
+ return sh(id, cmd);
+};
+
+describe("archive", () => {
+ beforeAll(async () => {
+ await runTerraformInit(import.meta.dir);
+ });
+
+ // Ensure required variables are enforced.
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "agent-123",
+ });
+
+ it("installs wrapper scripts to BIN_DIR and library to DATA_DIR", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ });
+
+ // The Terraform output should reflect defaults from main.tf.
+ expect(state.outputs.archive_path.value).toEqual(
+ "/tmp/coder-archive.tar.gz",
+ );
+
+ const { id, install } = await installArchive(state);
+ ensureRunOk("install", install);
+
+ expect(install.stdout).toContain(
+ `Installed archive library to: ${DATA_DIR}/archive-lib.sh`,
+ );
+ expect(install.stdout).toContain(
+ `Installed create script to: ${BIN_DIR}/coder-archive-create`,
+ );
+ expect(install.stdout).toContain(
+ `Installed extract script to: ${BIN_DIR}/coder-archive-extract`,
+ );
+ expect(await isExecutable(id, `${BIN_DIR}/coder-archive-create`)).toBe(
+ true,
+ );
+ expect(await isExecutable(id, `${BIN_DIR}/coder-archive-extract`)).toBe(
+ true,
+ );
+ });
+
+ it("uses sane defaults: creates gzip archive at the default path and logs to stderr", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ // Keep defaults: compression=gzip, output_dir=/tmp, archive_name=coder-archive.
+ });
+
+ const { id } = await installArchive(state);
+
+ const createTestdata = await bashRun(
+ id,
+ `mkdir ~/gzip; touch ~/gzip/defaults.txt`,
+ );
+ ensureRunOk("create testdata", createTestdata);
+
+ const run = await bashRun(id, `${BIN_DIR}/coder-archive-create`);
+ ensureRunOk("archive-create default run", run);
+
+ // Only the archive path should print to stdout.
+ expect(run.stdout.trim()).toEqual("/tmp/coder-archive.tar.gz");
+ expect(await fileExists(id, "/tmp/coder-archive.tar.gz")).toBe(true);
+
+ // Some useful diagnostics should be on stderr.
+ expect(run.stderr).toContain("Creating archive:");
+ expect(run.stderr).toContain("Compression: gzip");
+
+ const list = await listTar(id, "/tmp/coder-archive.tar.gz");
+ ensureRunOk("list default archive", list);
+ expect(list.stdout).toContain("gzip/defaults.txt");
+ }, 20000);
+
+ it("creates a gzip archive with explicit -f and includes extra CLI paths", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ // Provide a simple default path so we can assert contents.
+ paths: `["~/gzip"]`,
+ compression: "gzip",
+ });
+
+ const { id } = await installArchive(state);
+
+ const createTestdata = await bashRun(
+ id,
+ `mkdir ~/gzip; touch ~/gzip/test.txt; touch ~/gziptest.txt`,
+ );
+ ensureRunOk("create testdata", createTestdata);
+
+ const out = "/tmp/backup/test-archive.tar.gz";
+ const run = await bashRun(
+ id,
+ `${BIN_DIR}/coder-archive-create -f ${out} ~/gziptest.txt`,
+ );
+ ensureRunOk("archive-create gzip explicit -f", run);
+
+ expect(run.stdout.trim()).toEqual(out);
+ expect(await fileExists(id, out)).toBe(true);
+
+ const list = await sh(id, `tar -tzf ${out}`);
+ ensureRunOk("tar -tzf contents (gzip)", list);
+ expect(list.stdout).toContain("gzip/test.txt");
+ expect(list.stdout).toContain("gziptest.txt");
+ }, 20000);
+
+ it("creates a zstd-compressed archive when requested via CLI override", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ paths: `["/etc/hostname"]`,
+ // Module default is gzip, override at runtime to zstd.
+ });
+
+ const { id } = await installArchive(state);
+
+ const out = "/tmp/backup/zstd-archive.tar.zst";
+ const run = await bashRun(
+ id,
+ `${BIN_DIR}/coder-archive-create --compression zstd -f ${out}`,
+ );
+ ensureRunOk("archive-create zstd", run);
+
+ expect(run.stdout.trim()).toEqual(out);
+
+ // Check integrity via zstd and that tar can list it.
+ ensureRunOk("zstd -t", await sh(id, `test -f ${out} && zstd -t -q ${out}`));
+ ensureRunOk("tar --zstd -tf", await sh(id, `tar --zstd -tf ${out}`));
+ }, 30000);
+
+ it("creates an uncompressed tar when compression=none", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ // Keep module defaults but override at runtime.
+ });
+
+ const { id } = await installArchive(state);
+
+ const out = "/tmp/backup/raw-archive.tar";
+ const run = await bashRun(
+ id,
+ `${BIN_DIR}/coder-archive-create --compression none -f ${out}`,
+ );
+ ensureRunOk("archive-create none", run);
+
+ expect(run.stdout.trim()).toEqual(out);
+ ensureRunOk("tar -tf (none)", await sh(id, `tar -tf ${out} >/dev/null`));
+ }, 20000);
+
+ it("applies exclude patterns from Terraform", async () => {
+ // Include a file, but also exclude it via Terraform defaults to ensure
+ // exclusion flows through.
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ paths: `["/etc/hostname"]`,
+ exclude_patterns: `["/etc/hostname"]`,
+ });
+
+ const { id } = await installArchive(state);
+
+ const out = "/tmp/backup/excluded.tar.gz";
+ const run = await bashRun(id, `${BIN_DIR}/coder-archive-create -f ${out}`);
+ ensureRunOk("archive-create with exclude_patterns", run);
+
+ const list = await sh(id, `tar -tzf ${out}`);
+ ensureRunOk("tar -tzf contents (exclude)", list);
+ expect(list.stdout).not.toContain("etc/hostname"); // Excluded by Terraform default.
+ }, 20000);
+
+ it("adds a run_on_stop script when enabled", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ create_on_stop: true,
+ });
+
+ const coderScripts = state.resources.filter(
+ (r) => r.type === "coder_script",
+ );
+ // Installer (run_on_start) + run_on_stop.
+ expect(coderScripts.length).toBe(2);
+ });
+
+ it("extracts a previously created archive into a target directory", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ paths: `["/etc/hostname"]`,
+ compression: "gzip",
+ });
+
+ const { id } = await installArchive(state);
+
+ // Create archive.
+ const out = "/tmp/backup/extract-test.tar.gz";
+ const created = await bashRun(
+ id,
+ `${BIN_DIR}/coder-archive-create -f ${out} /etc/hosts`,
+ );
+ ensureRunOk("create for extract", created);
+
+ // Extract archive.
+ const extractDir = "/tmp/extract";
+ const extract = await bashRun(
+ id,
+ `${BIN_DIR}/coder-archive-extract -f ${out} -C ${extractDir}`,
+ );
+ ensureRunOk("archive-extract", extract);
+
+ // Verify a known file exists after extraction.
+ const exists = await sh(
+ id,
+ `test -f ${extractDir}/etc/hosts && echo ok || echo no`,
+ );
+ expect(exists.stdout.trim()).toEqual("ok");
+ }, 20000);
+
+ it("honors Terraform defaults without CLI args (compression, name, output_dir)", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "agent-123",
+ compression: "zstd",
+ archive_name: "my-default",
+ output_dir: "/tmp/defout",
+ });
+
+ const { id } = await installArchive(state);
+
+ const run = await bashRun(id, `${BIN_DIR}/coder-archive-create`);
+ ensureRunOk("archive-create terraform defaults", run);
+ expect(run.stdout.trim()).toEqual("/tmp/defout/my-default.tar.zst");
+ expect(run.stderr).toContain("Creating archive:");
+ expect(run.stderr).toContain("Compression: zstd");
+ ensureRunOk(
+ "zstd -t",
+ await sh(id, "zstd -t -q /tmp/defout/my-default.tar.zst"),
+ );
+ ensureRunOk(
+ "tar --zstd -tf",
+ await sh(id, "tar --zstd -tf /tmp/defout/my-default.tar.zst"),
+ );
+ }, 30000);
+});
diff --git a/registry/coder-labs/modules/archive/main.tf b/registry/coder-labs/modules/archive/main.tf
new file mode 100644
index 000000000..93b453993
--- /dev/null
+++ b/registry/coder-labs/modules/archive/main.tf
@@ -0,0 +1,134 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ description = "The ID of a Coder agent."
+ type = string
+}
+
+variable "paths" {
+ description = "List of files/directories to include in the archive. Defaults to the current directory."
+ type = list(string)
+ default = ["."]
+}
+
+variable "exclude_patterns" {
+ description = "Exclude patterns for the archive."
+ type = list(string)
+ default = []
+}
+
+variable "compression" {
+ description = "Compression algorithm for the archive. Supported: gzip, zstd, none."
+ type = string
+ default = "gzip"
+ validation {
+ condition = contains(["gzip", "zstd", "none"], var.compression)
+ error_message = "compression must be one of: gzip, zstd, none."
+ }
+}
+
+variable "archive_name" {
+ description = "Optional archive base name without extension. If empty, defaults to \"coder-archive\"."
+ type = string
+ default = "coder-archive"
+}
+
+variable "output_dir" {
+ description = "Optional output directory where the archive will be written. Defaults to \"/tmp\"."
+ type = string
+ default = "/tmp"
+}
+
+variable "directory" {
+ description = "Change current directory to this path before creating or extracting the archive. Defaults to the user's home directory."
+ type = string
+ default = "~"
+}
+
+variable "create_on_stop" {
+ description = "If true, also create a run_on_stop script that creates the archive automatically on workspace stop."
+ type = bool
+ default = false
+}
+
+variable "extract_on_start" {
+ description = "If true, the installer will wait for an archive and extract it on start."
+ type = bool
+ default = false
+}
+
+variable "extract_wait_timeout_seconds" {
+ description = "Timeout (seconds) to wait for an archive when extract_on_start is true."
+ type = number
+ default = 5
+}
+
+# Provide a stable script filename and sensible defaults.
+locals {
+ extension = var.compression == "gzip" ? ".tar.gz" : var.compression == "zstd" ? ".tar.zst" : ".tar"
+
+ # Ensure ~ is expanded because it cannot be expanded inside quotes in a
+ # templated shell script.
+ paths = [for v in var.paths : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
+ exclude_patterns = [for v in var.exclude_patterns : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
+ directory = replace(var.directory, "/^~(\\/|$)/", "$$HOME$1")
+ output_dir = replace(var.output_dir, "/^~(\\/|$)/", "$$HOME$1")
+
+ archive_path = "${local.output_dir}/${var.archive_name}${local.extension}"
+}
+
+output "archive_path" {
+ description = "Full path to the archive file that will be created, extracted, or both."
+ value = local.archive_path
+}
+
+# This script installs the user-facing archive script into $CODER_SCRIPT_BIN_DIR.
+# The installed script can be run manually by the user to create an archive.
+resource "coder_script" "archive_start_script" {
+ agent_id = var.agent_id
+ display_name = "Archive"
+ icon = "/icon/folder.svg"
+ run_on_start = true
+ start_blocks_login = var.extract_on_start
+
+ # Render the user-facing archive script with Terraform defaults, then write it to $CODER_SCRIPT_BIN_DIR
+ script = templatefile("${path.module}/run.sh", {
+ TF_LIB_B64 = base64encode(file("${path.module}/scripts/archive-lib.sh")),
+ TF_PATHS = join(" ", formatlist("%q", local.paths)),
+ TF_EXCLUDE_PATTERNS = join(" ", formatlist("%q", local.exclude_patterns)),
+ TF_COMPRESSION = var.compression,
+ TF_ARCHIVE_PATH = local.archive_path,
+ TF_DIRECTORY = local.directory,
+ TF_EXTRACT_ON_START = var.extract_on_start,
+ TF_EXTRACT_WAIT_TIMEOUT = var.extract_wait_timeout_seconds,
+ })
+}
+
+# Optionally, also register a run_on_stop script that creates the archive automatically
+# when the workspace stops. It simply invokes the installed archive script.
+resource "coder_script" "archive_stop_script" {
+ count = var.create_on_stop ? 1 : 0
+ agent_id = var.agent_id
+ display_name = "Archive"
+ icon = "/icon/folder.svg"
+ run_on_stop = true
+ start_blocks_login = false
+
+ # Call the installed script. It will log to stderr and print the archive path to stdout.
+ # We redirect stdout to stderr to avoid surfacing the path in system logs if undesired.
+ # Remove the redirection if you want the path to appear in stdout on stop as well.
+ script = <<-EOT
+ #!/usr/bin/env bash
+ set -euo pipefail
+ "$CODER_SCRIPT_BIN_DIR/coder-archive-create"
+ EOT
+}
diff --git a/registry/coder-labs/modules/archive/run.sh b/registry/coder-labs/modules/archive/run.sh
new file mode 100644
index 000000000..ee4235c41
--- /dev/null
+++ b/registry/coder-labs/modules/archive/run.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+LIB_B64="${TF_LIB_B64}"
+EXTRACT_ON_START="${TF_EXTRACT_ON_START}"
+EXTRACT_WAIT_TIMEOUT="${TF_EXTRACT_WAIT_TIMEOUT}"
+
+# Set script defaults from Terraform.
+DEFAULT_PATHS=(${TF_PATHS})
+DEFAULT_EXCLUDE_PATTERNS=(${TF_EXCLUDE_PATTERNS})
+DEFAULT_COMPRESSION="${TF_COMPRESSION}"
+DEFAULT_ARCHIVE_PATH="${TF_ARCHIVE_PATH}"
+DEFAULT_DIRECTORY="${TF_DIRECTORY}"
+
+# 1) Decode the library into $CODER_SCRIPT_DATA_DIR/archive-lib.sh (static, sourceable).
+LIB_PATH="$CODER_SCRIPT_DATA_DIR/archive-lib.sh"
+lib_tmp="$(mktemp -t coder-module-archive.XXXXXX))"
+trap 'rm -f "$lib_tmp" 2>/dev/null || true' EXIT
+
+# Decode the base64 content safely.
+if ! printf '%s' "$LIB_B64" | base64 -d > "$lib_tmp"; then
+ echo "ERROR: Failed to decode archive library from base64." >&2
+ exit 1
+fi
+chmod 0644 "$lib_tmp"
+mv "$lib_tmp" "$LIB_PATH"
+
+# 2) Generate the wrapper scripts (create and extract).
+create_wrapper() {
+ tmp="$(mktemp -t coder-module-archive.XXXXXX)"
+ trap 'rm -f "$tmp" 2>/dev/null || true' EXIT
+ cat > "$tmp" << EOF
+#!/usr/bin/env bash
+set -euo pipefail
+
+. "$LIB_PATH"
+
+# Set defaults from Terraform (through installer).
+$(
+ declare -p \
+ DEFAULT_PATHS \
+ DEFAULT_EXCLUDE_PATTERNS \
+ DEFAULT_COMPRESSION \
+ DEFAULT_ARCHIVE_PATH \
+ DEFAULT_DIRECTORY
+ )
+
+$1 "\$@"
+EOF
+ chmod 0755 "$tmp"
+ mv "$tmp" "$2"
+}
+
+CREATE_WRAPPER_PATH="$CODER_SCRIPT_BIN_DIR/coder-archive-create"
+EXTRACT_WRAPPER_PATH="$CODER_SCRIPT_BIN_DIR/coder-archive-extract"
+create_wrapper archive_create "$CREATE_WRAPPER_PATH"
+create_wrapper archive_extract "$EXTRACT_WRAPPER_PATH"
+
+echo "Installed archive library to: $LIB_PATH"
+echo "Installed create script to: $CREATE_WRAPPER_PATH"
+echo "Installed extract script to: $EXTRACT_WRAPPER_PATH"
+
+# 3) Optionally wait for and extract an archive on start.
+if [[ $EXTRACT_ON_START = true ]]; then
+ . "$LIB_PATH"
+
+ archive_wait_and_extract "$EXTRACT_WAIT_TIMEOUT" quiet || {
+ exit_code=$?
+ if [[ $exit_code -eq 2 ]]; then
+ echo "WARNING: Archive not found in backup path (this is expected with new workspaces)."
+ else
+ exit $exit_code
+ fi
+ }
+fi
diff --git a/registry/coder-labs/modules/archive/scripts/archive-lib.sh b/registry/coder-labs/modules/archive/scripts/archive-lib.sh
new file mode 100644
index 000000000..b882352d6
--- /dev/null
+++ b/registry/coder-labs/modules/archive/scripts/archive-lib.sh
@@ -0,0 +1,279 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+log() {
+ printf '%s\n' "$@" >&2
+}
+warn() {
+ printf 'WARNING: %s\n' "$1" >&2
+}
+error() {
+ printf 'ERROR: %s\n' "$1" >&2
+ exit 1
+}
+
+load_defaults() {
+ DEFAULT_PATHS=("${DEFAULT_PATHS[@]:-.}")
+ DEFAULT_EXCLUDE_PATTERNS=("${DEFAULT_EXCLUDE_PATTERNS[@]:-}")
+ DEFAULT_COMPRESSION="${DEFAULT_COMPRESSION:-gzip}"
+ DEFAULT_ARCHIVE_PATH="${DEFAULT_ARCHIVE_PATH:-/tmp/coder-archive.tar.gz}"
+ DEFAULT_DIRECTORY="${DEFAULT_DIRECTORY:-$HOME}"
+}
+
+ensure_tools() {
+ command -v tar > /dev/null 2>&1 || error "tar is required"
+ case "$1" in
+ gzip)
+ command -v gzip > /dev/null 2>&1 || error "gzip is required for gzip compression"
+ ;;
+ zstd)
+ command -v zstd > /dev/null 2>&1 || error "zstd is required for zstd compression"
+ ;;
+ none) ;;
+ *)
+ error "Unsupported compression algorithm: $1"
+ ;;
+ esac
+}
+
+usage_archive_create() {
+ load_defaults
+
+ cat >&2 << USAGE
+Usage: coder-archive-create [OPTIONS] [[PATHS] ...]
+Options:
+ -c, --compression Compression algorithm (default "${DEFAULT_COMPRESSION}")
+ -C, --directory Change to directory (default "${DEFAULT_DIRECTORY}")
+ -f, --file Output archive file (default "${DEFAULT_ARCHIVE_PATH}")
+ -h, --help Show this help
+USAGE
+}
+
+archive_create() {
+ load_defaults
+
+ local compression="${DEFAULT_COMPRESSION}"
+ local directory="${DEFAULT_DIRECTORY}"
+ local file="${DEFAULT_ARCHIVE_PATH}"
+ local paths=("${DEFAULT_PATHS[@]}")
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c | --compression)
+ if [[ $# -lt 2 ]]; then
+ usage_archive_create
+ error "Missing value for $1"
+ fi
+ compression="$2"
+ shift 2
+ ;;
+ -C | --directory)
+ if [[ $# -lt 2 ]]; then
+ usage_archive_create
+ error "Missing value for $1"
+ fi
+ directory="$2"
+ shift 2
+ ;;
+ -f | --file)
+ if [[ $# -lt 2 ]]; then
+ usage_archive_create
+ error "Missing value for $1"
+ fi
+ file="$2"
+ shift 2
+ ;;
+ -h | --help)
+ usage_archive_create
+ exit 0
+ ;;
+ --)
+ shift
+ while [[ $# -gt 0 ]]; do
+ paths+=("$1")
+ shift
+ done
+ ;;
+ -*)
+ usage_archive_create
+ error "Unknown option: $1"
+ ;;
+ *)
+ paths+=("$1")
+ shift
+ ;;
+ esac
+ done
+
+ ensure_tools "$compression"
+
+ local -a tar_opts=(-c -f "$file" -C "$directory")
+ case "$compression" in
+ gzip)
+ tar_opts+=(-z)
+ ;;
+ zstd)
+ tar_opts+=(--zstd)
+ ;;
+ none) ;;
+ *)
+ error "Unsupported compression algorithm: $compression"
+ ;;
+ esac
+
+ for path in "${DEFAULT_EXCLUDE_PATTERNS[@]}"; do
+ if [[ -n $path ]]; then
+ tar_opts+=(--exclude "$path")
+ fi
+ done
+
+ # Ensure destination directory exists.
+ dest="$(dirname "$file")"
+ mkdir -p "$dest" 2> /dev/null || error "Failed to create output dir: $dest"
+
+ log "Creating archive:"
+ log " Compression: $compression"
+ log " Directory: $directory"
+ log " Archive: $file"
+ log " Paths: ${paths[*]}"
+ log " Exclude: ${DEFAULT_EXCLUDE_PATTERNS[*]}"
+
+ umask 077
+ tar "${tar_opts[@]}" "${paths[@]}"
+
+ printf '%s\n' "$file"
+}
+
+usage_archive_extract() {
+ load_defaults
+
+ cat >&2 << USAGE
+Usage: coder-archive-extract [OPTIONS]
+Options:
+ -c, --compression Compression algorithm (default "${DEFAULT_COMPRESSION}")
+ -C, --directory Change to directory (default "${DEFAULT_DIRECTORY}")
+ -f, --file Output archive file (default "${DEFAULT_ARCHIVE_PATH}")
+ -h, --help Show this help
+USAGE
+}
+
+archive_extract() {
+ load_defaults
+
+ local compression="${DEFAULT_COMPRESSION}"
+ local directory="${DEFAULT_DIRECTORY}"
+ local file="${DEFAULT_ARCHIVE_PATH}"
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c | --compression)
+ if [[ $# -lt 2 ]]; then
+ usage_archive_extract
+ error "Missing value for $1"
+ fi
+ compression="$2"
+ shift 2
+ ;;
+ -C | --directory)
+ if [[ $# -lt 2 ]]; then
+ usage_archive_extract
+ error "Missing value for $1"
+ fi
+ directory="$2"
+ shift 2
+ ;;
+ -f | --file)
+ if [[ $# -lt 2 ]]; then
+ usage_archive_extract
+ error "Missing value for $1"
+ fi
+ file="$2"
+ shift 2
+ ;;
+ -h | --help)
+ usage_archive_extract
+ exit 0
+ ;;
+ --)
+ shift
+ while [[ $# -gt 0 ]]; do
+ shift
+ done
+ ;;
+ -*)
+ usage_archive_extract
+ error "Unknown option: $1"
+ ;;
+ *)
+ shift
+ ;;
+ esac
+ done
+
+ ensure_tools "$compression"
+
+ local -a tar_opts=(-x -f "$file" -C "$directory")
+ case "$compression" in
+ gzip)
+ tar_opts+=(-z)
+ ;;
+ zstd)
+ tar_opts+=(--zstd)
+ ;;
+ none) ;;
+ *)
+ error "Unsupported compression algorithm: $compression"
+ ;;
+ esac
+
+ for path in "${DEFAULT_EXCLUDE_PATTERNS[@]}"; do
+ if [[ -n $path ]]; then
+ tar_opts+=(--exclude "$path")
+ fi
+ done
+
+ # Ensure destination directory exists.
+ mkdir -p "$directory" || error "Failed to create directory: $directory"
+
+ log "Extracting archive:"
+ log " Compression: $compression"
+ log " Directory: $directory"
+ log " Archive: $file"
+ log " Exclude: ${DEFAULT_EXCLUDE_PATTERNS[*]}"
+
+ umask 077
+ tar "${tar_opts[@]}" "${paths[@]}"
+
+ printf 'Extracted %s into %s\n' "$file" "$directory"
+}
+
+archive_wait_and_extract() {
+ load_defaults
+
+ local timeout="${1:-300}"
+ local quiet="${2:-}"
+ local file="${DEFAULT_ARCHIVE_PATH}"
+
+ local start now
+ start=$(date +%s)
+ while true; do
+ if [[ -f "$file" ]]; then
+ archive_extract -f "$file"
+ return 0
+ fi
+
+ if ((timeout <= 0)); then
+ break
+ fi
+ now=$(date +%s)
+ if ((now - start >= timeout)); then
+ break
+ fi
+ sleep 5
+ done
+
+ if [[ -z $quiet ]]; then
+ printf 'ERROR: Timed out waiting for archive: %s\n' "$file" >&2
+ fi
+ return 2
+}