Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/installer-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ jobs:
completion_file: .config/fish/completions/archdev.fish
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Cloud installer process contracts
if: matrix.shell_name == 'bash'
run: python3 scripts/test-cloud-installer.py
- name: Build fixture release
run: |
chmod +x install.sh scripts/create-unix-fixtures.sh
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ won't commit or push setup files without permission.
See the [installation guide](https://docs.archdev.ai/docs/start-here/install)
for what to expect, then ask your agent to review your changes with ArchDev.

## Claude Code cloud environments

In a personal Claude Code web environment, select **Custom** network access,
keep the default package-manager hosts, and allow `platform.archastro.ai` and
`archdev.ai`. Set `ARCHDEV_TOKEN=<your token>` in **Environment variables**.
Anyone using this environment can read that token, so do not share the environment.
Paste this into **Setup script**:

```bash
curl -fsSL https://raw.githubusercontent.com/ArchAstro/archdev/main/install-cloud.sh | bash
```

The script installs the latest CLI to `/usr/local/bin` and configures Claude
hooks in the setup user's home directory (`/root` in Claude's cloud VM).
Setup runs before environment variables are available; a temporary SessionStart
hook signs in when the session starts. Installation errors are reported without
preventing the session from starting. The initial download itself must succeed.
Claude runs SessionStart hooks in parallel: until direct `ARCHDEV_TOKEN` auth
is released, the first presence update can race login. Later CLI commands use
the stored login.
Use a repository with the ArchDev skill committed or enable it in your Claude
account. Snapshot refreshes pick up installer and CLI updates.

Maintainers: follow the [cloud release checklist](RELEASE_CHECKLIST.md).

## Distribution sources

This repository owns public installers, release metadata, downloadable
Expand Down
33 changes: 33 additions & 0 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Cloud installer release check

Run this when publishing `install-cloud.sh` or changing a CLI release's cloud
installation, hooks, or authentication behavior.

- [ ] `bash -n install-cloud.sh` and `python3 scripts/test-cloud-installer.py` pass.
The Installer Smoke Test workflow runs the process contracts on Linux for
pull requests, pushes to main, and manual dispatch. Its existing fixture
tests cover archive installation separately. These are local contract tests,
not proof of Claude's network proxy or hook dispatch.
- [ ] After the script is published at the public main-branch URL, configure a
personal Claude Code web environment using the README's one-line snippet,
network hosts, and token variable. Rebuild the snapshot so setup runs.
- [ ] Start a cloud session with the ArchDev skill available. Confirm setup
installed the CLI, the SessionStart contract appeared, and `archdev auth
status` and `archdev log messages --limit 1` succeed without a manual login.
Confirm presence appears and a requested team-room post succeeds in auto
mode without a credential appearing in the transcript. Record the CLI
version, date, and cloud session reference in the release evidence.
- [ ] When section 3.1's direct `ARCHDEV_TOKEN` resolution is in the released
CLI, remove the temporary login-hook block and verify the refreshed cloud
environment authenticates without creating `credentials.json`. Also remove
the exact legacy login hook from existing settings if the refresh retains
them; preserve all unrelated hooks.

Known temporary limitation: Claude dispatches SessionStart hooks in parallel.
The login hook does not guarantee authentication before the first presence
update. Section 3.1's direct token resolution closes this gap. Do not wrap the
stock startup command to impose ordering: CLI hook self-repair restores it.
Check the first presence update explicitly in the live proof.

The live cloud check must be completed after publication; a passing local
fixture run does not complete it.
70 changes: 70 additions & 0 deletions install-cloud.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Claude Code cloud environment setup, before session variables are available.
# Setup failures must not prevent the environment from starting.
set +e
set +u
set -o pipefail

warn() { printf 'ArchDev cloud setup: %s; continuing without blocking the session.\n' "$1" >&2; }

work_dir="$(mktemp -d)"
if [[ -z "$work_dir" ]]; then
warn 'could not create temporary directory'
exit 0
fi
settings_tmp=''
trap 'rm -rf "$work_dir"; [[ -z "$settings_tmp" ]] || rm -f "$settings_tmp"' EXIT

# Download first so a failed or partial transfer cannot execute half a script.
if ! curl -fsSL https://raw.githubusercontent.com/ArchAstro/archdev/main/install.sh -o "$work_dir/install.sh"; then
warn 'could not download install.sh'
exit 0
fi
install_dir="${ARCHDEV_INSTALL_DIR:-/usr/local/bin}"
if ! ARCHDEV_INSTALL_DIR="$install_dir" ARCHDEV_INSTALL_SKIP_PATH_UPDATE=true \
ARCHDEV_INSTALL_SKIP_COMPLETIONS=true bash "$work_dir/install.sh" --version latest; then
warn 'CLI installation failed'
exit 0
fi
if ! "$install_dir/archdev" repo hook setup --harness claude --force; then
warn 'Claude hook installation failed'
exit 0
fi

# Temporary bridge until released CLI auth resolves ARCHDEV_TOKEN directly.
# Keep the variable literal: setup has no token. Login happens at session start,
# alongside other SessionStart hooks. Claude runs these in parallel, so the
# first presence update can race login; direct env auth (section 3.1) fixes it.
if ! command -v jq >/dev/null 2>&1; then
warn 'jq is required to install the session login hook'
exit 0
fi
settings_dir="$HOME/.claude"
settings="$settings_dir/settings.json"
if ! mkdir -p "$settings_dir"; then
warn 'could not create Claude settings directory'
exit 0
fi
input="$settings"
if [[ ! -f "$input" ]]; then
printf '{}\n' >"$work_dir/settings.json"
input="$work_dir/settings.json"
fi
settings_tmp="$(mktemp "$settings_dir/.cloud-settings.XXXXXX")"
if [[ -z "$settings_tmp" ]]; then
warn 'could not create temporary Claude settings'
exit 0
fi
login='[ -z "${ARCHDEV_TOKEN:-}" ] || archdev auth login --token "$ARCHDEV_TOKEN" >/dev/null 2>&1 || true'
if jq --arg login "$login" '
.hooks.SessionStart = (
(.hooks.SessionStart // []) |
if any(.[]; any(.hooks[]?; .command == $login)) then .
else [{hooks: [{type: "command", command: $login, timeout: 30}]}] + . end
)
' "$input" >"$settings_tmp" && mv "$settings_tmp" "$settings"; then
printf 'ArchDev cloud setup complete. Authentication runs when a session starts.\n'
else
warn 'could not update Claude settings with the session login hook'
fi
exit 0
130 changes: 130 additions & 0 deletions scripts/test-cloud-installer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Local process contract tests; the release checklist owns the live cloud proof."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest

ROOT = Path(__file__).resolve().parent.parent


class CloudInstallerTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.bin = self.root / "bin"
self.bin.mkdir()
self.settings = self.root / ".claude/settings.json"
self.env = dict(os.environ, HOME=str(self.root), PATH=f"{self.bin}:{os.environ['PATH']}",
TEST_ROOT=str(self.root), ARCHDEV_INSTALL_DIR=str(self.root / "installed-bin"),
ARCHDEV_PRESENCE_DISABLED="1")
self.env.pop("ARCHDEV_TOKEN", None)
self.env.pop("BASH_ENV", None)
self.write_command("curl", '''
[ "${FAIL_DOWNLOAD:-}" != 1 ] || exit 22
[ "$1" = -fsSL ] && [ "$2" = https://raw.githubusercontent.com/ArchAstro/archdev/main/install.sh ] && [ "$3" = -o ] || exit 1
cp "$TEST_ROOT/installer" "$4"
''')
(self.root / "installer").write_text('''
set -eu
[ "$ARCHDEV_INSTALL_DIR" = "$TEST_ROOT/installed-bin" ]
[ "$ARCHDEV_INSTALL_SKIP_PATH_UPDATE" = true ]
[ "$ARCHDEV_INSTALL_SKIP_COMPLETIONS" = true ]
[ "$*" = "--version latest" ]
[ "${FAIL_INSTALL:-}" != 1 ]
mkdir -p "$ARCHDEV_INSTALL_DIR"
cp "$TEST_ROOT/bin/archdev" "$ARCHDEV_INSTALL_DIR/archdev"
printf installed > "$TEST_ROOT/installed"
''')
self.write_command("archdev", '''
case "$*" in
'repo hook setup --harness claude --force')
[ "${FAIL_HOOKS:-}" != 1 ] || exit 1
printf hooks > "$TEST_ROOT/hooks"
;;
'auth login --token '*)
printf '%s' "$4" > "$TEST_ROOT/login-token"
echo 'must not appear in session output'
exit "${FAIL_LOGIN:-0}"
;;
*) exit 1 ;;
esac
''')

def write_command(self, name, body):
path = self.bin / name
path.write_text("#!/usr/bin/env bash\nset -eu\n" + body)
path.chmod(0o755)

def run_setup(self, **env):
# The cloud runner has errexit enabled. A setup error must still return 0.
result = subprocess.run(["bash", "-e", str(ROOT / "install-cloud.sh")],
env=dict(self.env, **env), text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
return result

def test_setup_then_session_login_preserves_settings_and_is_repeatable(self):
# Setup: an existing user hook and preferences must survive cloud setup.
existing = {"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "echo existing"}]}]},
"permissions": {"allow": ["Bash(echo *)"]}}
self.settings.parent.mkdir()
self.settings.write_text(json.dumps(existing))
self.run_setup()
first = self.settings.read_text()
self.run_setup()
self.assertEqual(first, self.settings.read_text())
data = json.loads(first)
self.assertEqual(data["permissions"], existing["permissions"])
self.assertEqual(data["hooks"]["SessionStart"][1:], existing["hooks"]["SessionStart"])
self.assertTrue((self.root / "installed").exists())
self.assertTrue((self.root / "hooks").exists())
self.assertFalse((self.root / "login-token").exists())
# Session boundary: execute the saved hook with a token absent at setup.
command = data["hooks"]["SessionStart"][0]["hooks"][0]["command"]
for extra in ({}, {"ARCHDEV_TOKEN": "fixture-token"},
{"ARCHDEV_TOKEN": "fixture-token", "FAIL_LOGIN": "1"}):
result = subprocess.run(["bash", "-c", command], env=dict(self.env, **extra),
text=True, capture_output=True)
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout + result.stderr, "")
self.assertEqual((self.root / "login-token").read_text(), "fixture-token")
self.assertNotIn("fixture-token", first)

def test_setup_uses_installed_cli_even_when_path_has_another(self):
# Retain the executable in the fixture release, then poison PATH.
fixture = self.root / "released-archdev"
fixture.write_bytes((self.bin / "archdev").read_bytes())
fixture.chmod(0o755)
installer = self.root / "installer"
installer.write_text(installer.read_text().replace(
'$TEST_ROOT/bin/archdev', '$TEST_ROOT/released-archdev'))
self.write_command("archdev", 'exit 99\n')
result = self.run_setup()
self.assertIn("setup complete", result.stdout)
self.assertTrue((self.root / "hooks").exists())

def test_fresh_settings(self):
self.run_setup()
self.assertEqual(len(json.loads(self.settings.read_text())["hooks"]["SessionStart"]), 1)

def test_failures_report_without_blocking_startup(self):
for failure in ("FAIL_DOWNLOAD", "FAIL_INSTALL", "FAIL_HOOKS"):
with self.subTest(failure=failure):
result = self.run_setup(**{failure: "1"})
self.assertIn("continuing without blocking", result.stderr)
self.assertFalse(self.settings.exists())

def test_invalid_settings_are_not_overwritten(self):
self.settings.parent.mkdir()
self.settings.write_text("invalid json")
result = self.run_setup()
self.assertIn("could not update Claude settings", result.stderr)
self.assertEqual(self.settings.read_text(), "invalid json")
self.assertEqual(list(self.settings.parent.glob(".cloud-settings.*")), [])


if __name__ == "__main__":
unittest.main()
Loading