with one click
deploy
Deploy or share generated HQ artifacts through hq-deploy.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
Deploy or share generated HQ artifacts through hq-deploy.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
Mark a company as HQ Pro cloud-backed and sync its team setup.
Send HQ Sync direct messages, prompts, details, or scheduled notes to teammates.
Run a full bidirectional sync for cloud-backed HQ companies.
Scaffold a new HQ company AND optionally take it all the way to operational — business-discovery interview, seeded knowledge/workers/skills/projects, brand design packs (generated from website/PDF/Drive and bound to deploys via policy), connected integrations, org groups + ACL rules, teammate invites, and optional cloud agents.
Resolve HQ Sync conflicts by choosing local, cloud, or discard.
Manually log a time estimate.
| name | deploy |
| description | Deploy or share generated HQ artifacts through hq-deploy. |
| allowed-tools | Read, Grep, Bash(tar:*), Bash(curl:*), Bash(npm:*), Bash(npx:*), Bash(bun:*), Bash(pnpm:*), Bash(yarn:*), Bash(docker:*), Bash(git:*), Bash(ls:*), Bash(cat:*), Bash(aws:*), Bash(jq:*), Bash(op:*), Bash(source:*), Bash(pbcopy:*), Bash(chmod:*), Bash(node:*), Bash(lsof:*), Bash(mkdir:*), Bash(echo:*), Bash(wait:*), Bash(disown:*), Bash(test:*), Bash(touch:*), Bash(rm:*), Bash(paste:*), Bash(.claude/skills/deploy/scripts/identity-resolve.sh:*), Bash(.claude/skills/deploy/scripts/sensitivity-check.sh:*), Bash(.claude/skills/deploy/scripts/guardrails-check.sh:*), Bash(.claude/skills/deploy/scripts/deploy-api-request.sh:*), Bash(.claude/skills/deploy/scripts/og-inject.sh:*), Bash(.claude/skills/deploy/scripts/password-helper.sh:*), Edit, Write |
Skill for deploying web artifacts to hq-deploy infrastructure. Invoked directly via /deploy, or auto-triggered by auto-deploy-on-create (silent post-build) and hq-deploy-reinforcement (intent-to-share, deliverable PRDs) policies. The two paths share this same engine.
Guiding principle: quick casual handoff — preview, upload, link. Sensitive artifacts get the lowest-friction appropriate gate: password, Cognito company access, or an email allowlist when the user names recipients.
Every deployed app has exactly one edge access mode. New policy-aware deploys prefer the first-class access-policy endpoint for Cognito gates; the legacy access-mode endpoint remains the right path for password and email/domain allowlists.
| Mode | When to pick it | What it does |
|---|---|---|
public | Default. Casual handoff, anyone with the link. | No gate. App serves immediately. |
password | Sensitive content, casual share over Slack/email, recipients unknown ahead of time. | App owner sets a password (Argon2id-hashed). Visitors land on hq.{your-domain}.com/__access, enter the password, get a 24h hq-access JWT cookie scoped to .{your-domain}.com. |
company | Internal/company-only share; user says "restricted to org", "internal-only", "company-only"; or config prefers org-restricted deploys. | Visitors sign in with HQ Cognito on hq.{your-domain}.com/__access; the HQ access service checks active membership in the app's company before minting a policy-versioned hq-access cookie. |
selected | Specific HQ people/groups when the caller has resolvable HQ directory IDs. | Same Cognito flow as company, but only selected user/group IDs in the policy are accepted. Use only when IDs are known from the HQ directory, not from free-form names. |
private | Legacy sensitive sharing for known recipients by email/domain when Cognito company membership is not the desired gate. | Visitors must be signed in to hq-auth (auth.{your-domain}.com) AND their email must be on the app's allowlist. Lands on hq.{your-domain}.com/__private, which checks the session + allowlist and mints the same hq-access JWT. |
Pick company when the user asks for org/company/internal restriction. Pick private over password when the user gives concrete email/domain recipients ("share with [EMAIL] and the @example.com team") and did not ask for company-wide Cognito access. Pick password when sensitivity is detected but recipients are unspecified and config does not prefer org restriction.
Canonical mutation endpoint for switching between modes:
POST /api/apps/:id/access-mode {mode, password?} — atomic; clears the fields that don't belong to the chosen mode; wipes EmailGrant rows when leaving private so orphans can't silently re-activate on a future flip back.PUT /api/apps/:id/access-policy {mode, companyUid, users?, groups?, password?} — first-class policy endpoint for company, selected, and policy-versioned password. Use this for Cognito org gates.Legacy path gotcha: PATCH /api/apps/:id {passwordProtected, password} is rejected with 409 ACCESS_MODE_CONFLICT when the app is currently in private mode. Always use /access-mode to change modes; reserve PATCH for in-mode password rotation.
Email allowlist CRUD (only relevant in private mode):
GET /api/apps/:id/allowed-emailsPOST /api/apps/:id/allowed-emails {email} — accepts an exact address ([EMAIL]) or a @domain.tld pattern; idempotent; lowercased server-side.DELETE /api/apps/:id/allowed-emails/{patternKey} — patternKey URL-encoded.The engine is three phases, structured by data-dependency. Independent work runs in parallel via bash background jobs; I/O-heavy decisions live in inline scripts (no Task sub-agents — they cost 3–5s of spawn overhead each and the JWT/verdicts have to flow back to main anyway).
| Phase | What runs | Parallelism |
|---|---|---|
| Step 1 | Preferences + exclusions (gate) | inline, sequential |
| Phase A | Framework detect + design pass (A.1.5, generated static only) → Build (inline) ‖ Identity (script) ‖ Sensitivity (script) | detect + design sync, then 3-way parallel via & + wait |
| Phase B | Localhost preview (inline-bg) ‖ Guardrails (script) | 2-way parallel via & + wait |
| Phase C | Password gen → upload → wire password → announce → present link | sequential, hard-gated |
Hard ordering constraints (preserved from core/policies/hq-deploy-reinforcement.md):
appId which MUST exist before password persist + announceInline helper scripts (each is self-contained, returns one JSON line on stdout):
| Script | Purpose | Returns |
|---|---|---|
.claude/skills/deploy/scripts/identity-resolve.sh | Resolves Cognito JWT (cache → refresh → login); jq preferred, node via hook-lib.sh | {"status":"ok"|"login_required"|"missing_dependency",...} |
.claude/skills/deploy/scripts/sensitivity-check.sh <path> [user_msg] | Classifies artifact sensitivity (filename-list grep, no content surfaces) | {"sensitive":bool,"trigger":string|null} |
.claude/skills/deploy/scripts/guardrails-check.sh <output_dir> | Caps + builds tarball | {"pass":bool,"reason":string|null,"tarball_path":string,...} |
.claude/skills/deploy/scripts/deploy-api-request.sh | Makes a checked Phase C API/S3 request | validated body on stdout; safe failure diagnostic on stderr |
.claude/skills/deploy/scripts/og-inject.sh <output_dir> [base_url] [app_name] | Injects OG/Twitter preview tags; generates a 1200x630 card image when none exists | {"injected":int,"image":string,"changed":bool} |
.claude/skills/deploy/scripts/password-helper.sh | gen / announce / persist / lookup | password text, or persisted entry |
All scripts are deterministic, run in 0.3–0.5s, and never echo JWTs / artifact contents / matched PII.
Auto-deploy is opt-out. Honor user preference and rule out projects that shouldn't deploy.
$PREF_FILE is ~/.hq/deploy-prefs.json — a file owned exclusively by /deploy. The legacy ~/.hq/config.json is read-only here (backwards compat) and never written by this skill: that path is owned by the HQ Desktop App's strict HqConfig serde struct, and overlapping writers caused the HQ Desktop App to bail on every sync (see feedback_3ab4f113-2e7c-4e4e-a171-771b47a2b5fd).
PREF_FILE="$HOME/.hq/deploy-prefs.json"
LEGACY_PREF_FILE="$HOME/.hq/config.json"
# One-time migration: lift deploy-owned fields out of the legacy file so
# hq-sync can resume parsing ~/.hq/config.json as HqConfig. Read-only on the
# legacy path — never write back to it.
if [ ! -f "$PREF_FILE" ] && [ -f "$LEGACY_PREF_FILE" ]; then
LEGACY_DEFAULT=$(jq -r '.defaultOrg // empty' "$LEGACY_PREF_FILE" 2>/dev/null)
LEGACY_PREF=$(jq -r '.deploy.preference // empty' "$LEGACY_PREF_FILE" 2>/dev/null)
if [ -n "$LEGACY_DEFAULT" ] || [ -n "$LEGACY_PREF" ]; then
mkdir -p "$HOME/.hq"
jq -n --arg slug "$LEGACY_DEFAULT" --arg pref "$LEGACY_PREF" \
'{} | (if $slug != "" then .defaultOrg = $slug else . end)
| (if $pref != "" then .deploy.preference = $pref else . end)' \
> "$PREF_FILE"
fi
fi
if [ -f "$PREF_FILE" ]; then
DEPLOY_PREF=$(jq -r '.deploy.preference // "hq-deploy"' "$PREF_FILE" 2>/dev/null)
DEPLOY_ACCESS_SENSITIVE_DEFAULT=$(jq -r '.deploy.access.sensitiveDefault // "password"' "$PREF_FILE" 2>/dev/null)
DEPLOY_ACCESS_INTERNAL_DEFAULT=$(jq -r '.deploy.access.internalDefault // "company"' "$PREF_FILE" 2>/dev/null)
DEPLOY_ORG_RESTRICTED_BY_DEFAULT=$(jq -r '.deploy.access.orgRestrictedByDefault // false' "$PREF_FILE" 2>/dev/null)
else
DEPLOY_PREF="hq-deploy"
DEPLOY_ACCESS_SENSITIVE_DEFAULT="password"
DEPLOY_ACCESS_INTERNAL_DEFAULT="company"
DEPLOY_ORG_RESTRICTED_BY_DEFAULT="false"
fi
Valid values: hq-deploy (default), vercel, netlify, custom, none.
Access preference values:
.deploy.access.sensitiveDefault: password (default) or company.deploy.access.internalDefault: company (default).deploy.access.orgRestrictedByDefault: true makes sensitive deploys company-restricted unless the user asks for public/password/email-recipient sharingif [ -f "prd.json" ]; then
PRD_DEPLOY=$(jq -r '.metadata.deploy // "unset"' prd.json 2>/dev/null)
if [ "$PRD_DEPLOY" = "false" ]; then DEPLOY_PREF="none"; fi
fi
hq-deploy → continuevercel, netlify, custom → silently stop (user has their own pipeline)none → silently stop, skip even localhost previewmanifest.yaml vercel_projects[] lists this project → skip| Thing | How |
|---|---|
| Deploy context | Company context via $ORG_SLUG, or explicit personal context when the signed-in user has no companies |
| API endpoint | $HQ_DEPLOY_API → manifest services.hq-deploy.endpoint → https://api.indigo-hq.com (always-on public default) — via resolve-deploy-api.sh |
| App name | package.json name → current directory name, slug-cased |
Resolve the deploy API base concretely, up front — this must produce a non-empty
$API or Phase C stalls on an empty upload host. The resolver applies the chain above
and always falls back to the public default, so a fresh install (no manifest, no
$HQ_DEPLOY_API) still deploys:
API="$(.claude/skills/deploy/scripts/resolve-deploy-api.sh)"
# $API is now guaranteed non-empty (public default https://api.indigo-hq.com when
# nothing else is configured). Every Phase C hq-deploy call uses "$API/api/...".
The org the deploy targets MUST be resolved — never fall back to a hardcoded slug. Walk these priorities in order until one produces $ORG_SLUG. Priorities 1–4 don't need a JWT and run here; Priority 5 needs $JWT and runs in A.5 after the Phase A barrier. When the signed-in user has no company at all, A.5 sets PERSONAL_SCOPE=true and the deploy ships to their personal scope (Priority 5b). Priority 6 is the state-aware CTA path, reached only when the org is genuinely ambiguous (multi-org) or vault is unreachable.
| Priority | Source | Notes |
|---|---|---|
| 1 | --org=<slug> arg or HQ_ORG env | Explicit one-off override |
| 2 | Agent-supplied via session context | Agent sets HQ_ORG before invoking when conversation clearly implies a company |
| 3 | cwd → companies/{slug}/… segment | Running inside HQ tree |
| 4 | ~/.hq/deploy-prefs.json defaultOrg field | Persisted choice (legacy ~/.hq/config.json read as fallback for backwards-compat) |
| 5 | Single active vault membership | Auto-resolved + auto-written to defaultOrg (runs in A.5) |
| 5b | No active membership → personal scope | Signed in but no company: deploy to the auto-provisioned personal-<sub> scope (PERSONAL_SCOPE=true, runs in A.5). Upload proceeds with X-HQ-Deploy-Scope: personal |
| 6 | State-aware CTA (multi-org / unreachable — see C.5) | Only when the org is genuinely ambiguous or vault is down; preview already shown; skip upload |
Pre-JWT block (Priorities 1–4):
ORG_SLUG="${HQ_ORG:-}"
# Priority 3: cwd → companies/{slug}/...
if [ -z "$ORG_SLUG" ]; then
PWD_REAL="$(pwd -P)"
HQ_ROOT=""
D="$PWD_REAL"
while [ "$D" != "/" ] && [ -n "$D" ]; do
if [ -f "$D/companies/manifest.yaml" ]; then HQ_ROOT="$D"; break; fi
D="$(dirname "$D")"
done
if [ -n "$HQ_ROOT" ]; then
REL="${PWD_REAL#$HQ_ROOT/companies/}"
if [ "$REL" != "$PWD_REAL" ]; then
CAND="${REL%%/*}"
# Reject non-company paths like _template or stray files
if [ -d "$HQ_ROOT/companies/$CAND" ] && [[ "$CAND" != _* ]]; then
ORG_SLUG="$CAND"
fi
fi
fi
fi
# Priority 4: ~/.hq/deploy-prefs.json defaultOrg (legacy ~/.hq/config.json read-only fallback)
if [ -z "$ORG_SLUG" ] && [ -f "$HOME/.hq/deploy-prefs.json" ]; then
ORG_SLUG=$(jq -r '.defaultOrg // empty' "$HOME/.hq/deploy-prefs.json" 2>/dev/null)
fi
if [ -z "$ORG_SLUG" ] && [ -f "$HOME/.hq/config.json" ]; then
ORG_SLUG=$(jq -r '.defaultOrg // empty' "$HOME/.hq/config.json" 2>/dev/null)
fi
Priorities 5 and 6 run in A.5 once $JWT is in scope.
When the user says "I use Vercel", "don't deploy my stuff":
mkdir -p "$HOME/.hq"
if [ -f "$PREF_FILE" ]; then
jq '.deploy.preference = "vercel"' "$PREF_FILE" > "$PREF_FILE.tmp" && mv "$PREF_FILE.tmp" "$PREF_FILE"
else
echo '{"deploy":{"preference":"vercel"}}' > "$PREF_FILE"
fi
Then say once:
Got it — I won't offer auto-deploy. You can change this in
~/.hq/deploy-prefs.json.
After Step 1 resolves preferences, kick three workstreams off in the same shell command: Build inline, Identity script, Sensitivity script. Phase A completes when all three have returned.
# Skip rebuild if dist/index.html newer than newest source file
if [ -f "dist/index.html" ] || [ -f "out/index.html" ] || [ -f "build/client/index.html" ]; then
SKIP_BUILD=1
fi
# Detect framework + output dir + deploy type
if [ -f "next.config.js" ] || [ -f "next.config.mjs" ] || [ -f "next.config.ts" ]; then
FRAMEWORK="nextjs"; OUTPUT_DIR="out"; DEPLOY_TYPE="static"
elif [ -f "remix.config.js" ] || [ -f "remix.config.ts" ]; then
FRAMEWORK="remix"; OUTPUT_DIR="build/client"; DEPLOY_TYPE="ssr"
elif [ -f "astro.config.js" ] || [ -f "astro.config.mjs" ] || [ -f "astro.config.ts" ]; then
FRAMEWORK="astro"; OUTPUT_DIR="dist"; DEPLOY_TYPE="static"
elif [ -f "vite.config.js" ] || [ -f "vite.config.ts" ] || [ -f "vite.config.mjs" ]; then
FRAMEWORK="vite"; OUTPUT_DIR="dist"; DEPLOY_TYPE="static"
else
FRAMEWORK="static"; DEPLOY_TYPE="static"
for d in dist build out public .; do [ -f "$d/index.html" ] && OUTPUT_DIR="$d" && break; done
fi
# Backend API routes → upgrade to the `app` deploy type (per-app-function path).
# Orthogonal to the framework above: a root `api/` dir with >=1 handler file
# (api/**/*.{ts,js}) means the app ships backend routes, so it deploys as a
# static frontend PLUS an `api/*` per-app Lambda with keyless secret bindings —
# NOT Docker/ECR/ECS. Framework NAME is preserved (a Vite app with api/ stays
# framework=vite, type=app). No api/ dir (or empty) stays `static`. See the
# "App deploy type" section below and hq-deploy `src/deploy/function/`.
if [ "$DEPLOY_TYPE" != "ssr" ] && [ -n "$(find api -type f \( -name '*.ts' -o -name '*.js' \) 2>/dev/null | head -n1)" ]; then
DEPLOY_TYPE="app"
fi
# Package manager
if [ -f "bun.lockb" ] || [ -f "bun.lock" ]; then PM="bun"
elif [ -f "pnpm-lock.yaml" ]; then PM="pnpm"
elif [ -f "yarn.lock" ]; then PM="yarn"
else PM="npm"; fi
Default ON — this is the deploy-quality default. A plain, HQ-generated report/deck/summary should never ship looking like an unstyled document. Before the artifact is packaged, lift a self-authored single-page HTML to on-brand quality using the hq-design house system. This runs synchronously here (right after framework detection, before the Phase A fan-out) so the restyled file is what Phase B guardrails tars and Phase C uploads.
Gate — decide whether to run it:
DESIGN_PASS=1
[ "$FRAMEWORK" != "static" ] && DESIGN_PASS=0 # framework builds (Next/Vite/Astro/Remix) own their design — never touch them
[ -f "$OUTPUT_DIR/DESIGN.md" ] && DESIGN_PASS=0 # artifact already declares its own design system
[ "$(jq -r '.deploy.designPass // "true"' "$HOME/.hq/deploy-prefs.json" 2>/dev/null)" = "false" ] && DESIGN_PASS=0 # user disabled globally
case "$LATEST_USER_MSG" in # explicit opt-out in the latest message
*as-is*|*"as is"*|*"no design"*|*"skip design"*|*"no restyle"*|*"don't restyle"*|*"dont restyle"*|*"leave the styling"*|*"keep the design"*|*"keep the styling"*) DESIGN_PASS=0 ;;
esac
Scope is deliberately narrow: only FRAMEWORK=static single-page artifacts (reports, decks, summaries, briefs that HQ generated). Framework builds and already-designed artifacts pass through untouched.
When DESIGN_PASS=1, apply the pass — this is design work you do inline, not a script:
core/knowledge/public/hq-core/design-md-spec.md. If the design packs are installed (core/knowledge/public/design-styles/, core/knowledge/public/design-quality/), fold them in for a higher bar.$OUTPUT_DIR/index.html to that bar — deliberate type scale, spacing rhythm, color/token discipline, restraint, visual hierarchy; accessible (semantic HTML, aria) and responsive; wrap any animation in @media (prefers-reduced-motion: reduce)..html sub-pages (the static host SPA-fallbacks them — keep one self-contained index.html).Then continue to A.2 with the restyled artifact in place.
Launch Build (if needed), Identity, and Sensitivity simultaneously — each writes to its own tmp file, then wait syncs the barrier.
T_IDENTITY=$(mktemp -t hq-deploy-identity.XXXXXX)
T_SENSITIVITY=$(mktemp -t hq-deploy-sensitivity.XXXXXX)
T_BUILD=$(mktemp -t hq-deploy-build.XXXXXX)
# A.2.1 — Identity in background (script self-resolves cache/refresh/login)
.claude/skills/deploy/scripts/identity-resolve.sh > "$T_IDENTITY" 2>/dev/null &
IDENTITY_PID=$!
# A.2.2 — Sensitivity in background ($LATEST_USER_MSG = excerpt of latest user message, ≤200 chars)
.claude/skills/deploy/scripts/sensitivity-check.sh "$PWD" "$LATEST_USER_MSG" > "$T_SENSITIVITY" 2>/dev/null &
SENSITIVITY_PID=$!
# A.2.3 — Build in background (skipped if SKIP_BUILD)
if [ -z "$SKIP_BUILD" ]; then
( $PM install >/dev/null 2>&1 && $PM run build >/dev/null 2>&1 \
&& echo '{"status":"ok"}' || echo '{"status":"fail"}' ) > "$T_BUILD" &
BUILD_PID=$!
else
echo '{"status":"ok","skipped":true}' > "$T_BUILD"
BUILD_PID=""
fi
# Barrier — wait for all three
wait $IDENTITY_PID $SENSITIVITY_PID $BUILD_PID 2>/dev/null
IDENTITY_JSON=$(cat "$T_IDENTITY")
SENSITIVITY_JSON=$(cat "$T_SENSITIVITY")
BUILD_JSON=$(cat "$T_BUILD")
rm -f "$T_IDENTITY" "$T_SENSITIVITY" "$T_BUILD"
# Parse all Phase A verdicts through the shared jq-first, node-fallback engine.
# Do not use bare jq here: identity-resolve may have succeeded through node.
. core/scripts/hook-lib.sh
# hook-lib intentionally uses command -v for hot-path hooks, but /deploy must
# not trust a broken Windows app-execution alias or stale node shim.
if [ -n "$HQ_LIB_NODE" ] \
&& ! "$HQ_LIB_NODE" -e 'process.exit(0)' >/dev/null 2>&1; then
HQ_LIB_NODE=""
fi
if [ -z "$HQ_LIB_JQ" ] && [ -z "$HQ_LIB_NODE" ]; then
printf '%s\n' "Deploy requires jq or Node.js to parse its phase verdicts. Install jq: Windows: winget install jqlang.jq | choco install jq | scoop install jq; Linux: sudo apt install jq | sudo dnf install jq; macOS: brew install jq" >&2
IDENTITY_STATUS="missing_dependency"
JWT=""
LOGIN_REASON="missing_jq_and_node"
SENSITIVE="false"
SENSITIVITY_TRIGGER=""
BUILD_STATUS="fail"
else
IDENTITY_STATUS=$(printf '%s' "$IDENTITY_JSON" | hq_json_get status)
JWT=$(printf '%s' "$IDENTITY_JSON" | hq_json_get jwt)
LOGIN_REASON=$(printf '%s' "$IDENTITY_JSON" | hq_json_get reason)
SENSITIVE=$(printf '%s' "$SENSITIVITY_JSON" | hq_json_get sensitive)
SENSITIVITY_TRIGGER=$(printf '%s' "$SENSITIVITY_JSON" | hq_json_get trigger)
BUILD_STATUS=$(printf '%s' "$BUILD_JSON" | hq_json_get status)
fi
BUILD_STATUS == "fail" → abort the deploy entirely (silent skip). Localhost preview also skipped.IDENTITY_STATUS == "login_required" → mark Phase C upload as no-op; Phase B preview still runs.IDENTITY_STATUS == "missing_dependency" → not a sign-in problem. The A.3 parser has already printed per-OS jq guidance and set BUILD_STATUS=fail; abort the deploy without a login upsell or browser sign-in. When node exists, A.3 uses it and this hard-stop is not taken. Note: later Phase C steps still require jq even when identity itself used the node fallback.SENSITIVE == "true" → choose an access mode for Phase C:
"restricted to org", "company-only", "internal-only", "HQ members only"), set ACCESS_MODE=${DEPLOY_ACCESS_INTERNAL_DEFAULT:-company}."share with alice@…", "@example.com only", "private to the design team"), set ACCESS_MODE=private and parse the recipient list into ALLOW_PATTERNS (newline-separated, each either [EMAIL] or @domain.tld).DEPLOY_ORG_RESTRICTED_BY_DEFAULT=true or DEPLOY_ACCESS_SENSITIVE_DEFAULT=company, set ACCESS_MODE=company.ACCESS_MODE=password — the historical default for sensitive auto-deploy.The Identity script derives a filename-safe deploy user key from ${USER:-${USERNAME:-unknown}} (replacing characters outside [[:alnum:]_.-] with _) and owns the one-shot login attempt internally (${TMPDIR:-/tmp}/hq-deploy-login-attempted-<deploy-user-key>); the main agent does NOT re-trigger login mid-deploy. Token JSON is read with jq first, then node via core/scripts/hook-lib.sh (hq_json_get) — never a second ad-hoc JSON engine.
If $ORG_SLUG is still empty after Step 1e (Priorities 1–4) AND identity returned ok ($JWT is in scope), ask vault directly. The same person/membership endpoints the API middleware uses are publicly callable with the user's JWT.
This block is no-op when:
$ORG_SLUG already resolved in Step 1e (the common path — no extra round-trip)IDENTITY_STATUS != "ok" (no JWT — Phase C is already a no-op; State A handled at C.5)VAULT_API="${VAULT_API_URL:-https://4nfy67z28h.execute-api.us-east-1.amazonaws.com}"
ORG_RESOLUTION_STATE=""
ACTIVE_SLUGS=""
DEPLOY_CONTEXT_ARGS=()
# Set when the signed-in person belongs to NO company. The deploy still ships,
# under an auto-provisioned per-user PERSONAL scope. The upload below sends
# X-HQ-Deploy-Scope: personal so hq-deploy bypasses company resolution and
# find-or-creates the caller's `personal-<sub>` Org.
PERSONAL_SCOPE=""
if [ -z "$ORG_SLUG" ] && [ "$IDENTITY_STATUS" = "ok" ] && [ -n "$JWT" ]; then
# Resolve active memberships via GET /membership/me — it works for BOTH human
# (prs_) AND AGENT (agt_) callers because the vault service derives the agent
# entity from the JWT (custom:entityType=agent) server-side and unions its
# memberships. The OLD person-only chain (/entity/by-type/person ->
# /membership/person/{personUid}) returned NOTHING for an agent — agents have
# no person entity — so an agent deploy silently downgraded to personal scope,
# blocking company-scoped deploys for machine identities
# (feedback_1e8d78ed / DEV-1843: Nanit dashboard). resolve-deploy-org.sh turns
# the /membership/me body into ORG_SLUG / ORG_RESOLUTION_STATE / PERSONAL_SCOPE
# / ACTIVE_SLUGS / ACTIVE_COMPANY_UID (single active -> slug; none -> personal;
# many -> multi-org CTA; missing jq -> missing_dependency, NEVER personal).
MEMBERSHIPS_JSON=$(curl -s -H "Authorization: Bearer $JWT" "$VAULT_API/membership/me")
eval "$(printf '%s' "$MEMBERSHIPS_JSON" \
| .claude/skills/deploy/scripts/resolve-deploy-org.sh)"
# Single active membership whose companySlug wasn't enriched → resolve it via
# the entity lookup (fallback only).
if [ -z "$ORG_SLUG" ] && [ -z "$ORG_RESOLUTION_STATE" ] && [ -n "$ACTIVE_COMPANY_UID" ]; then
ORG_SLUG=$(curl -s -H "Authorization: Bearer $JWT" \
"$VAULT_API/entity/$ACTIVE_COMPANY_UID" \
| jq -r '.entity.slug // empty' 2>/dev/null)
fi
# Persist a resolved single-org as defaultOrg so future deploys skip the vault
# round-trip. ~/.hq/deploy-prefs.json only — never ~/.hq/config.json, which
# HQ Sync parses as a strict HqConfig.
if [ -n "$ORG_SLUG" ]; then
mkdir -p "$HOME/.hq"
PREFS="$HOME/.hq/deploy-prefs.json"
if [ -f "$PREFS" ]; then
jq --arg slug "$ORG_SLUG" '.defaultOrg = $slug' \
"$PREFS" > "$PREFS.tmp" && mv "$PREFS.tmp" "$PREFS"
else
printf '{"defaultOrg":"%s"}\n' "$ORG_SLUG" > "$PREFS"
fi
fi
fi
if [ -n "$ORG_SLUG" ]; then
DEPLOY_CONTEXT_ARGS=(--header "X-Org-Slug: $ORG_SLUG")
elif [ "$PERSONAL_SCOPE" = "true" ]; then
DEPLOY_CONTEXT_ARGS=(--header "X-HQ-Deploy-Scope: personal")
fi
After A.5, Phase C upload proceeds when either $ORG_SLUG is set (company deploy) or PERSONAL_SCOPE=true (signed-in user with no company → personal deploy). Every hq-deploy API call passes "${DEPLOY_CONTEXT_ARGS[@]}" to deploy-api-request.sh, which adds X-Org-Slug for company deploys and X-HQ-Deploy-Scope: personal for personal deploys. Never silently fall back to a hardcoded org. The remaining unresolved states (multi-org, missing_dependency, vault-unreachable) skip the upload and hit the state-aware CTA at C.5, which reads $ORG_RESOLUTION_STATE.
A personal deploy has no company to gate against, so company / selected access modes are impossible. Normalize the access mode chosen in A.4 before Phase C:
# Personal scope can't use a Cognito company/selected gate. Sensitive content
# falls back to a password; non-sensitive stays public (default, no policy).
# NOTE: this default (public, or password when sensitive) is the security-
# relevant choice flagged for confirmation at the hq-core-staging promotion gate.
if [ "$PERSONAL_SCOPE" = "true" ]; then
case "$ACCESS_MODE" in
company|selected)
ACCESS_MODE="password"
echo "[deploy] personal scope: no company to gate on — using a password instead of company access." >&2
;;
esac
fi
Once Build returns OUTPUT_DIR, kick off localhost preview (inline backgrounded server) and Guardrails (inline script) in parallel. Phase B completes when both return.
Pick a port, start a Node http server backgrounded with disown, write PID + URL to /tmp/hq-deploy-preview-$$.{pid,url}.
PORT=4321
while lsof -iTCP:"$PORT" -sTCP:LISTEN -Pn >/dev/null 2>&1; do
PORT=$((PORT + 1))
[ "$PORT" -gt 4400 ] && break
done
PIDFILE="/tmp/hq-deploy-preview-$$.pid"
URLFILE="/tmp/hq-deploy-preview-$$.url"
node -e "
const http = require('http');
const fs = require('fs');
const path = require('path');
const root = process.argv[1];
const port = Number(process.argv[2]);
const mime = { '.html':'text/html','.js':'application/javascript','.css':'text/css',
'.json':'application/json','.svg':'image/svg+xml','.png':'image/png',
'.jpg':'image/jpeg','.jpeg':'image/jpeg','.gif':'image/gif',
'.ico':'image/x-icon','.woff':'font/woff','.woff2':'font/woff2' };
http.createServer((req, res) => {
let p = path.join(root, decodeURIComponent(req.url.split('?')[0]));
try { if (fs.statSync(p).isDirectory()) p = path.join(p, 'index.html'); } catch {}
fs.readFile(p, (err, data) => {
if (err) { res.writeHead(404); return res.end('Not found'); }
res.writeHead(200, { 'Content-Type': mime[path.extname(p)] || 'application/octet-stream' });
res.end(data);
});
}).listen(port, () => console.log('ready'));
" "$OUTPUT_DIR" "$PORT" > /dev/null 2>&1 &
echo $! > "$PIDFILE"
echo "http://localhost:$PORT" > "$URLFILE"
disown
Persistence: server stays open until session end. On re-deploy in the same session, kill the old PID and re-use the port. Never accumulate orphans.
Walks $OUTPUT_DIR, applies caps, builds tarball, returns path + size + sha256.
T_GUARDRAILS=$(mktemp -t hq-deploy-guardrails.XXXXXX)
.claude/skills/deploy/scripts/guardrails-check.sh "$OUTPUT_DIR" > "$T_GUARDRAILS" 2>/dev/null &
GUARDRAILS_PID=$!
# Preview server is already disowned and serving — nothing to wait on for it.
wait $GUARDRAILS_PID 2>/dev/null
GUARDRAILS_JSON=$(cat "$T_GUARDRAILS")
rm -f "$T_GUARDRAILS"
GUARDRAILS_PASS=$(echo "$GUARDRAILS_JSON" | jq -r '.pass')
GUARDRAILS_REASON=$(echo "$GUARDRAILS_JSON" | jq -r '.reason // empty')
TARBALL_PATH=$(echo "$GUARDRAILS_JSON" | jq -r '.tarball_path // empty')
TARBALL_SIZE=$(echo "$GUARDRAILS_JSON" | jq -r '.size_bytes // 0')
TARBALL_SHA256=$(echo "$GUARDRAILS_JSON" | jq -r '.sha256 // empty')
FILE_COUNT=$(echo "$GUARDRAILS_JSON" | jq -r '.file_count // 0')
Caps (encoded in script): project-root disqualifiers (Dockerfile, serverless.yml, sst.config.*, prisma/, migrations/, knex/drizzle configs); >100 files → fail; tarball >10MB gzipped → fail.
If GUARDRAILS_PASS=false, skip Phase C entirely. Localhost preview already served the user.
Always print this — it's the guaranteed-working feedback:
Preview: http://localhost:{port}
Every API call carries Authorization: Bearer $JWT.
Pre-conditions:
BUILD_STATUS="ok", IDENTITY_STATUS="ok" (otherwise skip upload, jump to C.5 with preview-only outcome)$ORG_SLUG is non-empty or PERSONAL_SCOPE=true (otherwise — multi-org or vault-unreachable — skip upload, jump to C.5 with the appropriate state-aware CTA — see C.5)GUARDRAILS_PASS=true (otherwise abort silently)Only generated when ACCESS_MODE=password. Private mode uses the user's hq-auth identity, no password needed.
if [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "password" ]; then
PW=$(.claude/skills/deploy/scripts/password-helper.sh gen)
# Format: adjective-noun-NN, e.g. foxtrot-river-92
fi
# All Phase C HTTP calls go through this helper. It records the response body
# separately from the HTTP status, validates the expected response shape, and
# exits before the next stage on any failure. It never prints auth headers or
# presigned query strings. `--no-auth` is only for the direct S3 PUT.
DEPLOY_SCOPE="company"
[ "$PERSONAL_SCOPE" = "true" ] && DEPLOY_SCOPE="personal"
deploy_request() {
local stage="$1"
shift
HQ_DEPLOY_JWT="$JWT" .claude/skills/deploy/scripts/deploy-api-request.sh \
--stage "$stage" --org "${ORG_SLUG:--}" --scope "$DEPLOY_SCOPE" \
"${DEPLOY_CONTEXT_ARGS[@]}" "$@"
}
# GET /api/apps returns {apps: [...]}
APPS_JSON=$(deploy_request app-list --method GET --url "$API/api/apps" \
--expect '.apps | type == "array"') || exit 1
APP_ID=$(echo "$APPS_JSON" | jq -r --arg name "$APP_NAME" '.apps[] | select(.name == $name) | .id' | head -1)
APP_SUBDOMAIN=$(echo "$APPS_JSON" | jq -r --arg name "$APP_NAME" '[.apps[] | select(.name == $name)][0].subdomain // empty')
if [ -z "$APP_ID" ]; then
# POST /api/apps requires {name, type}
APP_RESPONSE=$(deploy_request app-creation --method POST --url "$API/api/apps" \
--header 'Content-Type: application/json' \
--data "{\"name\": \"$APP_NAME\", \"type\": \"$DEPLOY_TYPE\"}" \
--expect '(.id | type == "string" and length > 0)') || exit 1
APP_ID=$(echo "$APP_RESPONSE" | jq -r '.id')
APP_SUBDOMAIN=$(echo "$APP_RESPONSE" | jq -r '.subdomain')
fi
# Subdomain anchors both the upload (appSlug) and the preview-tag base URL; fall
# back to the app-name slug if the API response didn't surface one.
if [ -z "$APP_SUBDOMAIN" ] || [ "$APP_SUBDOMAIN" = "null" ]; then APP_SUBDOMAIN="$APP_NAME"; fi
Before tarring for upload, add Open Graph / Twitter Card tags so a shared link unfurls with a real card (title + description + 1200x630 image) instead of a bare URL. This is what makes Slack/iMessage/Twitter render a rich preview. Runs only for DEPLOY_TYPE=static, and never overwrites a page's author-supplied og:title. The base URL is derived from the resolved subdomain so og:url/og:image are absolute. If injection changes the output, the tarball is rebuilt so the deploy manifest's size + sha256 match the bytes actually uploaded.
if [ "$DEPLOY_TYPE" = "static" ]; then
BASE_URL="https://${APP_SUBDOMAIN}.${HQ_DEPLOY_DOMAIN:-indigo-hq.com}"
OG_JSON=$(.claude/skills/deploy/scripts/og-inject.sh "$OUTPUT_DIR" "$BASE_URL" "$APP_NAME")
if [ "$(echo "$OG_JSON" | jq -r '.changed')" = "true" ]; then
NEW_TAR=$(mktemp -t hq-deploy-tar.XXXXXX).tar.gz
tar -czf "$NEW_TAR" -C "$OUTPUT_DIR" . 2>/dev/null
rm -f "$TARBALL_PATH"
TARBALL_PATH="$NEW_TAR"
TARBALL_SIZE=$(stat -c%s "$TARBALL_PATH" 2>/dev/null || stat -f%z "$TARBALL_PATH" 2>/dev/null || echo 0)
if command -v sha256sum >/dev/null 2>&1; then
TARBALL_SHA256=$(sha256sum "$TARBALL_PATH" | awk '{print $1}')
else
TARBALL_SHA256=$(shasum -a 256 "$TARBALL_PATH" | awk '{print $1}')
fi
fi
fi
The Guardrails script already produced $TARBALL_PATH, $TARBALL_SIZE, $TARBALL_SHA256 — reuse them, do not re-tar:
The org field below is informational only — the hq-deploy API resolves the
target org from the caller's auth context and context headers. Company deploys
carry X-Org-Slug: $ORG_SLUG; personal deploys carry
X-HQ-Deploy-Scope: personal and leave $ORG_SLUG empty. Never send both
headers on the same request.
DEPLOY_RESPONSE=$(deploy_request deploy-creation --method POST --url "$API/api/deploys" \
--header 'Content-Type: application/json' \
--data "{\"appSlug\": \"$APP_SUBDOMAIN\", \"org\": \"$ORG_SLUG\", \"manifest\": {\"files\": [], \"size\": $TARBALL_SIZE, \"sha256\": \"$TARBALL_SHA256\"}}" \
--expect '(.deployId | type == "string" and length > 0) and (.presignedUrl | type == "string" and length > 0)') || exit 1
DEPLOY_ID=$(echo "$DEPLOY_RESPONSE" | jq -r '.deployId')
PRESIGNED_URL=$(echo "$DEPLOY_RESPONSE" | jq -r '.presignedUrl')
# Direct S3 PUT — presigned URL carries its own signature, no Authorization header
deploy_request s3-upload --no-auth --method PUT --url "$PRESIGNED_URL" \
--header 'Content-Type: application/gzip' --upload-file "$TARBALL_PATH" || exit 1
COMPLETE_RESPONSE=$(deploy_request deploy-completion --method POST \
--url "$API/api/deploys/$DEPLOY_ID/complete" \
--header 'Content-Type: application/json' --data "{\"appSlug\": \"$APP_SUBDOMAIN\"}" \
--expect '(.url | type == "string" and length > 0)') || exit 1
LIVE_URL=$(echo "$COMPLETE_RESPONSE" | jq -r '.url')
rm -f "$TARBALL_PATH"
api/* → per-app Lambda)When DEPLOY_TYPE=app (a root api/ dir was detected in A.1), the app ships a
static frontend and backend api/* handlers. The client-side flow is the
same presigned-tarball upload as static — tar the whole build (frontend +
api/ dir) and push it through POST /api/deploys → S3 PUT → …/complete
exactly as in "Static upload" above. The control plane does the backend work:
it esbuild-bundles api/**/*.{ts,js} into a per-app Lambda, mounts it behind a
shared front-door HTTP API, and maps the app's subdomain to it. There is no
Docker/ECR/ECS step — app is distinct from the dormant SSR path.
Runtime secrets → SecretBindings (not env vars in the bundle). A backend handler that needs a secret (DB URL, Slack webhook, API key) must NOT have the value baked into the tarball. Instead bind the app to named HQ-Pro vault secrets; the per-app function reads its own bound secrets keylessly at runtime via SigV4 against its app identity. Author the bindings before deploy:
# List current bindings
BINDINGS_JSON=$(deploy_request secret-bindings-list --method GET \
--url "$API/api/apps/$APP_ID/secret-bindings" \
--expect '.secrets | type == "array"') || exit 1
echo "$BINDINGS_JSON" | jq '.secrets'
# Bind vault secrets the caller currently has read on (references only — no values).
# Requires an hq-pro token; each ref is validated against the vault + deployer grant.
deploy_request secret-bindings-write --method PUT \
--url "$API/api/apps/$APP_ID/secret-bindings" \
"${HQ_PRO_REQUEST_HEADERS[@]}" --header 'Content-Type: application/json' \
--data '{"companyUid":"'"$COMPANY_UID"'","secrets":[{"name":"SLACK_WEBHOOK_URL"},{"name":"DATABASE_URL"}]}'
Public + secret-backed deploy gate (ADVISORY-first — STOP before deploying). The trigger is deliberately simple — no source analysis, no route inspection:
(access mode is PUBLIC) AND (runtime SecretBinding count > 0)
Fetch access mode + binding count and decide PUBLIC the same way the edge
validator does (public = accessMode=="public", or legacy rows where neither
privateMode nor passwordProtected is true; any password/company/selected/
private gate is NOT public):
APP_JSON=$(deploy_request app-read --method GET --url "$API/api/apps/$APP_ID" \
--expect 'type == "object"') || exit 1
ACCESS_MODE=$(echo "$APP_JSON" | jq -r '.accessMode // empty')
BINDINGS_JSON=$(deploy_request secret-bindings-list --method GET \
--url "$API/api/apps/$APP_ID/secret-bindings" \
--expect '.secrets | type == "array"') || exit 1
BINDING_COUNT=$(echo "$BINDINGS_JSON" | jq -r '.secrets | length')
BINDING_COUNT > 0 → STOP. Secret-backed endpoints would be
world-callable. Surface the risk in full prose and require the user to EITHER
add an access gate (password/company/private) and re-run, OR explicitly
acknowledge the risk for this deploy — then pass acknowledgePublicSecretRisk
through the deploy call so the acceptance is recorded/audited. Do not proceed
silently.BINDING_COUNT == 0, or any gate present → proceed with no prompt.Canonical, testable trigger lives in hq-deploy at
src/deploy/function/security-gate.ts(evaluateDeployGate/assertDeployGate); this step is the operator-facing surface. The full app-runtime contract lives alongside it in the hq-deploy repo's app/api-routes runtime spec.
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin "$ECR_URI"
docker build -t "$APP_NAME:$VERSION" .
docker tag "$APP_NAME:$VERSION" "$ECR_URI/$APP_NAME:$VERSION"
docker push "$ECR_URI/$APP_NAME:$VERSION"
deploy_request ssr-deploy --method POST --url "$API/api/apps/$APP_ID/deploy" \
--header 'Content-Type: application/json' \
--data "{\"image_tag\": \"$VERSION\", \"deploy_type\": \"ssr\"}" || exit 1
deploy-api-request.sh is the only Phase C request path. On a non-2xx response it
stops the phase before a later request runs and reports the stage, method,
sanitized URL, status, API code/message, request ID, and non-secret org/scope.
It strips Authorization values and the full query string (including presigned S3
credentials). A 401 is explicitly marked auth=stale-login action=preview-only:
fall back to the no-upload branch, present preview, and do not re-trigger login
mid-deploy. A 403 is marked authorization=forbidden with the target org/scope
so authorization failures are not mistaken for malformed responses.
After upload, with appId in hand. Branch on ACCESS_MODE. Use PUT /access-policy for first-class Cognito policy modes (company, selected, policy-versioned password); use POST /access-mode for legacy password/private transitions and allowlist cleanup.
For grantee validation, send the id token when available. identity-resolve.sh returns the hq-deploy access token as $JWT; read the companion id token from the local token file only inside the shell and never echo it.
TOKEN_FILE="$HOME/.hq/cognito-tokens.json"
HQ_PRO_JWT=""
if [ -f "$TOKEN_FILE" ]; then
HQ_PRO_JWT=$(jq -r '.idToken // .accessToken // empty' "$TOKEN_FILE" 2>/dev/null)
fi
HQ_PRO_REQUEST_HEADERS=()
[ -n "$HQ_PRO_JWT" ] && HQ_PRO_REQUEST_HEADERS=(--header "X-HQ-Pro-Authorization: Bearer $HQ_PRO_JWT")
if [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "password" ]; then
deploy_request access-mode-password --method POST \
--url "$API/api/apps/$APP_ID/access-mode" \
--header 'Content-Type: application/json' \
--data "{\"mode\": \"password\", \"password\": \"$PW\"}" >/dev/null || exit 1
elif [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "company" ]; then
COMPANY_UID=$(curl -sS -H "Authorization: Bearer ${HQ_PRO_JWT:-$JWT}" \
"$VAULT_API/entity/by-slug/company/$ORG_SLUG" \
| jq -r '.entity.uid // empty' 2>/dev/null)
if [ -z "$COMPANY_UID" ]; then
echo "[deploy] company access requested but companyUid could not be resolved for $ORG_SLUG; falling back to password mode." >&2
PW=${PW:-$(.claude/skills/deploy/scripts/password-helper.sh gen)}
ACCESS_MODE=password
deploy_request access-mode-password-fallback --method POST \
--url "$API/api/apps/$APP_ID/access-mode" \
--header 'Content-Type: application/json' \
--data "{\"mode\": \"password\", \"password\": \"$PW\"}" >/dev/null || exit 1
else
deploy_request access-policy-company --method PUT \
--url "$API/api/apps/$APP_ID/access-policy" \
"${HQ_PRO_REQUEST_HEADERS[@]}" --header 'Content-Type: application/json' \
--data "{\"mode\":\"company\",\"companyUid\":\"$COMPANY_UID\",\"users\":[],\"groups\":[]}" >/dev/null || exit 1
fi
elif [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "selected" ]; then
# SELECTED_USERS_JSON / SELECTED_GROUPS_JSON must be arrays of {id} objects
# resolved from the HQ directory. Do not invent IDs from display names.
COMPANY_UID=${COMPANY_UID:-$(curl -sS -H "Authorization: Bearer ${HQ_PRO_JWT:-$JWT}" \
"$VAULT_API/entity/by-slug/company/$ORG_SLUG" \
| jq -r '.entity.uid // empty' 2>/dev/null)}
deploy_request access-policy-selected --method PUT \
--url "$API/api/apps/$APP_ID/access-policy" \
"${HQ_PRO_REQUEST_HEADERS[@]}" --header 'Content-Type: application/json' \
--data "{\"mode\":\"selected\",\"companyUid\":\"$COMPANY_UID\",\"users\":${SELECTED_USERS_JSON:-[]},\"groups\":${SELECTED_GROUPS_JSON:-[]}}" >/dev/null || exit 1
elif [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "private" ]; then
# Flip the app to private mode, then grant each pattern.
deploy_request access-mode-private --method POST \
--url "$API/api/apps/$APP_ID/access-mode" \
--header 'Content-Type: application/json' --data '{"mode": "private"}' >/dev/null || exit 1
# ALLOW_PATTERNS is one pattern per line (set in A.4 from the user message).
while IFS= read -r PATTERN; do
[ -z "$PATTERN" ] && continue
deploy_request allowed-email-grant --method POST \
--url "$API/api/apps/$APP_ID/allowed-emails" \
--header 'Content-Type: application/json' \
--data "{\"email\": \"$PATTERN\"}" >/dev/null || exit 1
done <<< "$ALLOW_PATTERNS"
fi
Legacy PATCH gotcha: never call PATCH /api/apps/:id {passwordProtected: true, password: ...} on an app that may already be in private mode — it returns 409 ACCESS_MODE_CONFLICT because the server refuses to bypass the mutex. The /access-mode endpoint above handles the transition cleanly.
Treat gate setup as unproven until all three checks pass: the mutation returns a
2xx status, an authenticated GET /api/apps/{appId} reread reports the expected
protection state, and an anonymous request to the live URL returns 302. Poll
the reread + anonymous redirect a small bounded number of times for propagation.
Do not announce a selected access mode, password, or live link as gated before
that proof succeeds. If a company gate cannot be proven, attempt the password
fallback with the same checks; if that also cannot be proven, fail the deploy
closed rather than reporting a potentially public artifact.
if [ "$SENSITIVE" = "true" ]; then
APP_JSON=$(deploy_request auth-gate-reread --method GET \
--url "$API/api/apps/$APP_ID" --expect 'type == "object"') || exit 1
PROTECTED=$(echo "$APP_JSON" | jq -r '.passwordProtected // false')
PRIVATE=$(echo "$APP_JSON" | jq -r '.privateMode // false')
POLICY_MODE=$(echo "$APP_JSON" | jq -r '.accessPolicy.mode // .accessMode // empty')
if [ "$ACCESS_MODE" = "company" ] || [ "$ACCESS_MODE" = "selected" ]; then
[ "$POLICY_MODE" != "$ACCESS_MODE" ] && echo "[deploy] auth-gate verify: expected accessPolicy.mode=$ACCESS_MODE got ${POLICY_MODE:-empty} for $APP_ID — re-run /deploy if this artifact must stay gated." >&2
else
EXPECTED_FLAG="$([ "$ACCESS_MODE" = "password" ] && echo "$PROTECTED" || echo "$PRIVATE")"
if [ "$EXPECTED_FLAG" != "true" ]; then
echo "[deploy] auth-gate verify: mode=$ACCESS_MODE protected=$PROTECTED private=$PRIVATE for $APP_ID — re-run /deploy if this artifact must stay gated." >&2
fi
fi
fi
Failure handling: do not auto-delete the deploy, but exit non-zero and do not present it as gated when neither the selected mode nor password fallback is proven.
if [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "password" ]; then
.claude/skills/deploy/scripts/password-helper.sh announce \
"$APP_SUBDOMAIN" "$PW" "$SENSITIVITY_TRIGGER"
# announce: prints once to stderr, copies to clipboard via pbcopy,
# persists to ~/.hq/deploy-passwords.json (mode 0600), keyed by slug.
fi
No password to announce. Surface who got access so the user can sanity-check before sharing the link:
if [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "private" ]; then
PATTERN_LIST=$(echo "$ALLOW_PATTERNS" | paste -sd ', ' -)
echo "[deploy] private mode: $PATTERN_LIST can sign in via auth.{your-domain}.com to view." >&2
fi
No password to announce. Surface the org restriction once:
if [ "$SENSITIVE" = "true" ] && [ "$ACCESS_MODE" = "company" ]; then
echo "[deploy] company mode: active $ORG_SLUG members can sign in with HQ to view." >&2
fi
The only user-visible output. Keep it casual.
On success (non-sensitive): weave naturally:
On success (personal scope, PERSONAL_SCOPE=true): the deploy went to the
user's own personal space (no company). Say so once, casually, so they know it
isn't org-restricted — and only mention a password if the content was sensitive
(personal sensitive deploys use password mode, see A.5 normalization):
password mode line as below, plus a one-time note that it landed in your personal space.On success (sensitive, password mode): mention password ONCE:
Live at
https://$APP_SUBDOMAIN.indigo-hq.com— password copied to your clipboard (also saved to~/.hq/deploy-passwords.json).
If the user asks "what was the password?" later, do NOT re-emit. Tell them:
Run
jq -r '."$APP_SUBDOMAIN".password' ~/.hq/deploy-passwords.jsonto retrieve it.
On success (sensitive, private mode): name the allowlist once, no password mention:
Live at
https://$APP_SUBDOMAIN.{your-domain}.com— gated to {[EMAIL], @example.com}. They'll sign in via auth.{your-domain}.com on first visit.
On success (sensitive, company mode): name the org gate once, no password mention:
Live at
https://$APP_SUBDOMAIN.{your-domain}.com— restricted to active$ORG_SLUGmembers. They'll sign in with HQ on first visit.
For changes after the fact, point at the CLI rather than re-orchestrating from this skill:
Run
hq-deploy access share $APP_SUBDOMAIN <email|@domain>to add a teammate, or… unshare …to revoke.
The ~/.hq/deploy-passwords.json path is in .claude/settings.json Read deny list — the session can't pull it back into context.
On no-identity path (Phase A returned login_required) — State A: preview URL was already emitted in Phase B; emit upsell once if /tmp/hq-deploy-upsold-<deploy-user-key> doesn't exist:
DEPLOY_USER_KEY=${USER:-${USERNAME:-unknown}}
DEPLOY_USER_KEY=${DEPLOY_USER_KEY//[^[:alnum:]_.-]/_}
UPSOLD_FILE="/tmp/hq-deploy-upsold-$DEPLOY_USER_KEY"
if [ ! -f "$UPSOLD_FILE" ]; then
echo "Looks like you don't have an HQ account yet. Create one free at https://onboarding.indigo-hq.com and I'll deploy this to the web next time."
touch "$UPSOLD_FILE"
fi
On signed-in-but-org-unresolved path (IDENTITY_STATUS=ok, PERSONAL_SCOPE not set, and $ORG_SLUG still empty after A.5): emit the appropriate state-aware CTA. This covers multi-org, missing_dependency, and the vault-unreachable defensive case — the no-orgs state deploys to personal scope above. Preview URL was already shown in Phase B, so the CTA pairs with that, not in place of it:
PREVIEW_URL=$(cat "$URLFILE" 2>/dev/null || echo "http://localhost:$PORT")
case "$ORG_RESOLUTION_STATE" in
multi-org)
# State C — multiple memberships, no default
echo "You're a member of multiple companies (${ACTIVE_SLUGS:-multiple}). Tell me which one to deploy to (\"deploy this to <slug>\") or set a default (\"make <slug> my default org\"). Preview: $PREVIEW_URL"
;;
missing_dependency)
# Memberships could not be inspected. Never infer personal scope.
echo "I couldn't inspect your HQ memberships because jq is missing. Install jq (Windows: winget/choco/scoop; Linux: apt/dnf; macOS: brew), then rerun /deploy. Preview: $PREVIEW_URL"
;;
*)
# Defensive — JWT was valid but vault was unreachable. Don't silently
# default to indigo; surface a recoverable next step.
echo "Couldn't resolve a deploy target right now. Set HQ_ORG=<slug> for this run, or \"make <slug> my default org\" to persist. Preview: $PREVIEW_URL"
;;
esac
Skip the rest of Phase C (no upload, no password, no link) — local preview is already up.
On upload failure (after Phase A passed but Phase C failed):
Then move on. Deploy is never the main event.
| Script | Input | Returns |
|---|---|---|
identity-resolve.sh | (none — reads ~/.hq/cognito-tokens.json) | {"status":"ok","jwt":"...","expires_at":<epoch-ms>,"source":"cache|refresh|login"} or {"status":"login_required","reason":"..."} or {"status":"missing_dependency","dep":"jq|node","install":"..."} (agent must show install help, not login upsell) |
sensitivity-check.sh <path> [user_msg] | artifact path + latest user message excerpt | {"sensitive":bool,"trigger":"companies-data-path|private-repo|pii-detected|financial-filename|user-stated-private"|null} |
guardrails-check.sh <output_dir> | build output directory | {"pass":bool,"reason":string|null,"tarball_path":string,"size_bytes":int,"sha256":string,"file_count":int} |
og-inject.sh <output_dir> [base_url] [app_name] | static build dir (+ live base URL) | {"injected":int,"image":"generated|existing|none","changed":bool} |
password-helper.sh gen | — | <adjective-noun-NN> on stdout |
password-helper.sh announce <slug> <pw> [trigger] | slug + password | stderr message + pbcopy + writes ~/.hq/deploy-passwords.json |
All scripts:
Authorization: Bearer headers only.repos/public/hq-deploy/cli/ remains for CI/CD pipelines (uses its own auth flow)./hq-share — grant a teammate access to a vault path/dm — send the live link to someone