用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/popmechanic/Julian --skill deploy命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
正在显示 SKILL.md
| name | deploy |
| description | Deploy Julian to an exe.xyz VM (new instance or update existing) |
| user-invocable | true |
| allowed-tools | ["Bash(ssh:*)","Bash(scp:*)","Bash(curl:*)","Bash(git:*)","Bash(gh:*)","Bash(mkdir:*)","Bash(bun:*)","Bash(source:*)","Read","Write","Glob"] |
Deploy Julian to an exe.xyz VM. Two paths: provision a new VM or update an existing one. The instance registry at deploy/instances.json tracks which VMs have been provisioned.
Determine the target VM name:
$ARGUMENTS is provided, use it as the VM name (e.g., /deploy screen-test)julian-<branch> (e.g., branch screen → julian-screen)NAME VALIDATION: exe.dev requires 5–52 characters, lowercase alphanumeric with single hyphens (no leading/trailing hyphen, no doubles). Validate BEFORE any exe.dev command and stop with a clear error if invalid — "soul" fails at 4 characters.
PRODUCTION SAFETY: If the resolved VM name is exactly julian (the production instance), STOP and warn the user before proceeding. Only proceed after explicit confirmation.
Read deploy/instances.json. If the target VM name exists in the registry, run the Update path. Otherwise, run the Provision path.
If deploy/instances.json doesn't exist, create it as {}.
Full first-time setup. Run all steps in order.
git rev-parse --abbrev-ref HEAD. This branch is the deploy branch — it is checked out on the VM in Step P4 and recorded in the registry in Step P8.git pull (stop on merge conflicts)git status --porcelain (warn but don't block)git pushhttps://<vmname>.exe.xyz/)Read the local .env and check for VITE_OIDC_ISSUER, VITE_OIDC_CLIENT_ID, VITE_SYNC_URL, and VITE_GATE_URL:
https://souls.exe.xyz and https://gate.julian.soul.store):
extract all for later. Proceed..env:
VITE_OIDC_ISSUER=https://souls.exe.xyz
VITE_OIDC_CLIENT_ID=<client id from the Pocket ID admin>
VITE_SYNC_URL=https://sync.julian.soul.store
VITE_GATE_URL=https://gate.julian.soul.store
The Bun server also honors OIDC_ISSUER (falls back to the VITE_ value) and
OIDC_JWKS_JSON (test seam); the token audience is VITE_OIDC_CLIENT_ID.
The VM needs all four VITE_ variables for auth and service communication.
IMPORTANT: All SSH commands targeting the VM must include -o StrictHostKeyChecking=accept-new.
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 <vmname>.exe.xyz echo ok
If unreachable, create it:
ssh exe.dev new --name=<vmname>
ssh exe.dev share set-public <vmname> # deliberate step: makes the VM publicly reachable
Note: exe.dev commands run through a lobby REPL — arguments containing spaces
need remote-side quotes: ssh exe.dev "new --name=x --comment='two words'".
Wait for boot (up to 90 seconds):
for i in $(seq 1 9); do
ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 <vmname>.exe.xyz echo ok && break
echo "Attempt $i failed, retrying in 10s..."
sleep 10
done
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "curl -fsSL https://bun.sh/install | bash && sudo apt-get update -qq && sudo apt-get install -y npm inotify-tools"
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "sudo mkdir -p /opt/julian && sudo chown exedev:exedev /opt/julian && mkdir -p /home/exedev/mailbox"
Generate an SSH key for push access:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "ssh-keygen -t ed25519 -f ~/.ssh/julian-deploy -N '' -C '<vmname>-deploy'"
Configure SSH to use it for GitHub:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "mkdir -p ~/.ssh && cat >> ~/.ssh/config << 'SSHEOF'
Host github.com
IdentityFile ~/.ssh/julian-deploy
StrictHostKeyChecking accept-new
SSHEOF"
Add the deploy key to GitHub with write access:
DEPLOY_KEY=$(ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cat ~/.ssh/julian-deploy.pub")
gh repo deploy-key add - --repo popmechanic/Julian --title "<vmname>-deploy" --allow-write <<< "$DEPLOY_KEY"
Verify the key registered — the add can fail silently with no output:
gh repo deploy-key list --repo popmechanic/Julian | grep "<vmname>-deploy"
If missing, re-run the add once; if still missing, STOP and report.
If the key title already exists, skip — it's fine.
Clone the repo and configure git identity:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "git clone git@github.com:popmechanic/Julian.git /opt/julian && cd /opt/julian && git checkout <branch>"
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && git config user.name 'Julian' && git config user.email 'julian@exe.xyz'"
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && /home/exedev/.bun/bin/bun install && cd /opt/julian/shared && /home/exedev/.bun/bin/bun install && cd /opt/julian/app && /home/exedev/.bun/bin/bun install"
The SPA build happens in Step P6d, AFTER .env exists — not here. Vite bakes
VITE_* values into the bundle at build time: built without .env, the app
thinks auth is disabled, skips the passkey gate, and CONNECT TO CLAUDE 401s.
Use the VITE values from pre-flight (do NOT hardcode):
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cat > /opt/julian/.env << 'ENVEOF'
VITE_OIDC_ISSUER=<value from local .env>
VITE_OIDC_CLIENT_ID=<value from local .env>
VITE_SYNC_URL=https://sync.julian.soul.store
VITE_GATE_URL=https://gate.julian.soul.store
ALLOWED_ORIGIN=https://<vmname>.exe.xyz
BROKER_URL=https://gate.julian.soul.store
ENVEOF"
Only tier T2 (public config) variables ship to a VM — see
deploy/secrets-manifest.md for every credential's tier. Never any secret:
T1 capabilities reach VMs as broker verbs, T0 keys never leave the Mac.
The VM needs a lease to authenticate with the gate. Run the knock on the VM
over ssh — run verbatim on the Mac it would write the Mac's own
~/.julian/gate-lease.json with the VM's tokens. This step comes after P5 and
P6 on purpose: dependencies are installed and /opt/julian/.env exists. It
requires Marcus at /approve:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && BROKER_URL=https://gate.julian.soul.store /home/exedev/.bun/bin/bun scripts/door-knock.ts --name <vmname>-web --purpose 'VM web instance'"
Two details that break this command if you change them:
BROKER_URL is written out literally, not read from .env by a
subshell. A $(grep … .env …) inside the double-quoted ssh argument expands
on the Mac, against the Mac's working directory — not on the VM. The gate
URL is tier T2 public config (deploy/secrets-manifest.md), so writing it
inline is safe, and it matches the value Step P6 put in /opt/julian/.env./home/exedev/.bun/bin/bun), as everywhere
else in this skill. Non-interactive ssh does not load the profile that puts
~/.bun/bin on PATH; a bare bun returns bash: bun: command not found.The command prints instructions to visit the gate approval page. Marcus
approves there; the command writes the lease file to the VM's
~/.julian/gate-lease.json (the universal default on every machine;
JULIAN_LEASE_FILE overrides). Decommissioning a VM means revoking its lease
(bun scripts/door-leases.ts revoke <door>) — the lease lives outside
/opt/julian, so a re-provisioned VM keeps it.
Enable Agent Teams (disabled by default) so Julian can spawn and manage agent teammates:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "mkdir -p /home/exedev/.claude && cat > /home/exedev/.claude/settings.json << 'SETTINGSEOF'
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}
SETTINGSEOF"
A new VM's callback URL must be registered with the Pocket ID client or
sign-in fails with redirect_uri ... is not registered:
source .env && bun deploy/pocketid-register-callback.ts <vmname>
POCKETID_API_KEY in the local .env. The script prints the
manual step (Pocket ID admin -> OIDC Clients -> Julian -> add the callback).
Have the user do it, confirm they re-opened the client and saw the URL
listed (saves fail silently when admin sessions expire), then continue.Run only after Step P6 wrote /opt/julian/.env — the build reads it:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian/app && /home/exedev/.bun/bin/bunx vite build"
app/dist is gitignored and the server serves it at root — skip this build
and the site is blank. Verify the env made it into the bundle:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "grep -rl souls.exe.xyz /opt/julian/app/dist/assets/ >/dev/null && echo baked || echo MISSING-ENV-REBUILD-NEEDED"
Verify the build baked both the sync and the gate URL. The failure this catches
is silent by construction: a bundle built before .env carried VITE_SYNC_URL
serves fine, renders fine, and syncs nowhere — nothing in the page says so.
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && /home/exedev/.bun/bin/bun scripts/verify-app-bundle.ts"
/opt/julian/.env first, then re-run Step P6d, then re-run this check.Instance .env first, always. The bundle is baked from the .env on the
box, so the box's .env must be right before any build. Editing this skill
changes nothing on a VM that is already provisioned: its /opt/julian/.env was
written by whatever version of Step P6 ran the day it was provisioned, and it
predates VITE_SYNC_URL / VITE_GATE_URL. Reaching an already-provisioned box
takes a deliberate act on that box — Step U1b on the Update path here, and the
release runbook for the fleet. The build never repairs the .env; the .env
is repaired, and only then does the build mean anything.
There are exactly two units: julian and julian-screen. No other unit exists — do not install or reference one.
scp deploy/julian.service <vmname>.exe.xyz:/tmp/
scp deploy/julian-screen.service <vmname>.exe.xyz:/tmp/
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "sudo cp /tmp/julian.service /etc/systemd/system/ && \
sudo cp /tmp/julian-screen.service /etc/systemd/system/ && \
sudo systemctl daemon-reload && \
sudo systemctl enable --now julian julian-screen"
Add the VM to deploy/instances.json:
{
"<vmname>": {
"url": "https://<vmname>.exe.xyz",
"provisioned": "<ISO 8601 timestamp>",
"branch": "<git branch used for first deploy>"
}
}
Read the existing file, merge the new entry, write it back. Commit and push the updated registry so other machines know about it:
git add deploy/instances.json
git commit -m "Register <vmname> instance"
git push
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "systemctl is-active julian julian-screen"
curl -sf https://<vmname>.exe.xyz/ | head -5
curl -sf https://<vmname>.exe.xyz/api/health
Report: URL and service status. /api/health returns needsSetup: true
until the one-time Anthropic OAuth handshake — open https://<vmname>.exe.xyz/
and complete the CONNECT TO CLAUDE screen with Marcus's account. On a fresh
VM this is expected, not an error.
Fast path — just sync code and restart. This is the common case.
deploy/instances.json and take its branch field — that is the deploy branch for this VM: it is the branch Step U1 checks out on the VM. The change analysis below is separate — it compares the server's current commit to the local HEAD you are about to deploy.git pull (stop on merge conflicts)git status --porcelain (warn but don't block)git pushBefore deploying, assess the scope of changes. Get the server's current commit and diff it against what you're about to deploy:
SERVER_HEAD=$(ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && git rev-parse HEAD")
git diff --stat $SERVER_HEAD HEAD
git diff --name-only $SERVER_HEAD HEAD
Run those three lines as one command so $SERVER_HEAD is set when the diffs run,
and write the hash down — Step U2 needs you to paste it in literally.
Classify the deploy based on what changed:
Content only (soul/, memory/, catalog.xml, docs/):
Small code change (1-3 files changed in server/ or frontend, under ~100 lines total):
Large code change (4+ files changed, or 200+ lines, or structural changes to server.ts):
julian), suggest: "Want to deploy to a fresh test VM first? I can provision one with /deploy test."Dependency change (package.json modified):
Frontend change (any diffed path under app/ or shared/):
No changes (server is already on the same commit):
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && git fetch origin && git checkout <branch from instances.json> && git pull"
If git pull fails because Julian has uncommitted changes:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && git stash && git pull && git stash pop"
If there are merge conflicts after stash pop, report them to the user.
.env (before any rebuild)/opt/julian/.env is not in git — git pull never updates it. A VM
provisioned before a variable was added to Step P6 still has the old file, so
the newest code plus the oldest .env bakes a bundle that is missing exactly
the variable the new code needs. Run this check on every Update, and run it
before the Step U2 rebuild — the instance .env comes first, the build
second.
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz 'for v in VITE_OIDC_ISSUER VITE_OIDC_CLIENT_ID VITE_SYNC_URL VITE_GATE_URL; do grep -q "^$v=" /opt/julian/.env || echo "MISSING $v"; done; echo checked'
The remote command is single-quoted on purpose: double quotes would expand $v
on the Mac and the loop would test the empty string on every pass.
checked alone: the instance .env is current. Continue to Step U2.MISSING <var> line: repair the file before going further, below.VITE_SYNC_URL and VITE_GATE_URL are tier T2 public config, identical on
every instance, so append the reported lines verbatim — but append only the
lines the check reported. A key added twice leaves a duplicate and the last one
wins, which is how a correct-looking .env bakes a wrong bundle:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cat >> /opt/julian/.env << 'ENVEOF'
VITE_SYNC_URL=https://sync.julian.soul.store
VITE_GATE_URL=https://gate.julian.soul.store
ENVEOF"
Keep ENVEOF at the start of its line — an indented terminator does not close a
<< heredoc, and the rest of your command is swallowed into the file.
A missing VITE_OIDC_ISSUER or VITE_OIDC_CLIENT_ID is a different matter:
those are per-instance values read from the local .env (Step P6), never
hardcoded here. STOP, write them from the local .env, then continue.
After any repair, treat this deploy as a frontend change no matter what the
change analysis said — the .env moved, so the bundle on the box is stale even
when no app/ file changed. Step U2 rebuilds it.
Both checks below diff against <server-head>. Substitute it yourself with the
pre-pull server commit hash captured during Change analysis (the SERVER_HEAD
value), writing the actual hash into the command — never rely on a shell
variable surviving between commands, since each Bash invocation starts a fresh
shell and $SERVER_HEAD would expand to empty.
Check if package.json changed in the pull:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && git diff --name-only <server-head> 2>/dev/null | grep -q package.json && echo changed || echo unchanged"
If changed (or if in doubt), run:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && /home/exedev/.bun/bin/bun install"
Check whether the pull touched the frontend:
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "cd /opt/julian && git diff --name-only <server-head> HEAD | grep -qE '^(app|shared)/' && echo rebuild || echo skip"
HEAD~1 and ORIG_HEAD are both wrong here. The git checkout in Step U1 never
sets ORIG_HEAD and an already-up-to-date pull leaves any old value stale; a pull
that fast-forwards more than one commit puts the pre-pull state further back than
HEAD~1. Either mistake reports skip and leaves app/dist unbuilt — the
blank-site failure. The literal pre-pull hash is the only reliable reference.
On rebuild (or in doubt), and always when Step U1b changed the instance .env,
run the Step P5 installs, then the Step P6d build, then the Step P6e smoke check.
The VM's .env existing is not the same as its being current — that is what
Step U1b settles, and why it runs first. A build over a stale .env is the
silent failure: it succeeds, it reports success, and the app it produces syncs
nowhere. The smoke check is the only thing between that bundle and a green
deploy report, so do not skip it on the Update path.
ssh -o StrictHostKeyChecking=accept-new <vmname>.exe.xyz "sudo systemctl restart julian julian-screen"
curl -sf https://<vmname>.exe.xyz/api/health
Confirm the version field in the health response matches the current git hash. Report the URL and version.
ssh exe.dev ls to verify VM exists. If it does, wait longer or check exe.dev status.ssh <vmname>.exe.xyz "/home/exedev/.bun/bin/bun --version".ssh <vmname>.exe.xyz "journalctl -u julian -n 20 --no-pager". Common causes: missing Bun, missing jose dependency.ssh <vmname>.exe.xyz "ssh -T git@github.com". Re-run Step P4 if needed.deploy/instances.json and re-run — it will take the Provision path.BUNDLE SMOKE FAILED from Step P6e: the bundle was baked without a required VITE_* URL. Fix /opt/julian/.env (Step P6 on Provision, Step U1b on Update), re-run the Step P6d build, then re-run the check. Never wave it through — the app looks healthy and syncs nowhere..env. Run the Step U1b check; a VM provisioned before VITE_SYNC_URL existed needs the line added and the SPA rebuilt./tokens/with-email: Missing OIDC JWT configuration. Check Pocket ID admin panel for proper token template setup.