@meffecta/agent 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ [
2
+ {
3
+ "name": "keep-recent-versions",
4
+ "action": { "type": "Keep" },
5
+ "mostRecentVersions": { "keepCount": 5 }
6
+ },
7
+ {
8
+ "name": "delete-untagged",
9
+ "action": { "type": "Delete" },
10
+ "condition": { "tagState": "UNTAGGED", "olderThan": "1d" }
11
+ },
12
+ {
13
+ "name": "delete-superseded",
14
+ "action": { "type": "Delete" },
15
+ "condition": { "tagState": "ANY", "olderThan": "30d" }
16
+ }
17
+ ]
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Step 1 of IMPLEMENTATION.md: create the GCP project this deployment will live in.
5
+ #
6
+ # Give the agent a project of its own, separate from any product project it will read:
7
+ # its service account gets project-wide secret access, and the mail tokens stored here
8
+ # must not be readable by another system's machinery.
9
+ #
10
+ # Asks for a display name, suggests a project ID from it, and creates the project. The
11
+ # ID is what every later command needs, and it is not always the one you asked for — if
12
+ # it is taken you pick another here, so you learn it now rather than at the first failure.
13
+ #
14
+ # Usage:
15
+ # scripts/create-project.sh
16
+ # scripts/create-project.sh --name "Acme Agent" --id acme-agent
17
+ # ORGANIZATION=123456789 scripts/create-project.sh # or FOLDER=...
18
+
19
+ NAME=""
20
+ PROJECT_ID=""
21
+
22
+ while [ $# -gt 0 ]; do
23
+ case "$1" in
24
+ --name) NAME="${2:?--name needs a value}"; shift 2 ;;
25
+ --id) PROJECT_ID="${2:?--id needs a value}"; shift 2 ;;
26
+ -h|--help) sed -n '4,18p' "$0"; exit 0 ;;
27
+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
28
+ esac
29
+ done
30
+
31
+ command -v gcloud >/dev/null || { echo "gcloud is required." >&2; exit 1; }
32
+
33
+ if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' 2>/dev/null | grep -q .; then
34
+ echo "No active gcloud account. Run: gcloud auth login" >&2
35
+ exit 1
36
+ fi
37
+
38
+ # Google's rules: 6-30 chars, lowercase letters, digits and hyphens, starts with a
39
+ # letter, does not end with one. Checked here so a typo fails instantly, not after a
40
+ # round trip.
41
+ valid_id() {
42
+ case "$1" in
43
+ [a-z]*[a-z0-9]) [ ${#1} -ge 6 ] && [ ${#1} -le 30 ] && [ -z "$(printf '%s' "$1" | tr -d 'a-z0-9-')" ] ;;
44
+ *) return 1 ;;
45
+ esac
46
+ }
47
+
48
+ suggest_id() {
49
+ printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//; s/-*$//' | cut -c1-30
50
+ }
51
+
52
+ if [ -z "${NAME}" ]; then
53
+ if [ ! -t 0 ]; then
54
+ echo "No display name given and nothing to prompt with. Use --name." >&2
55
+ exit 2
56
+ fi
57
+ printf 'Display name for the project (e.g. "Acme Agent"): '
58
+ IFS= read -r NAME
59
+ fi
60
+ [ -n "${NAME}" ] || { echo "A display name is required." >&2; exit 2; }
61
+
62
+ if [ -z "${PROJECT_ID}" ]; then
63
+ SUGGESTED=$(suggest_id "${NAME}")
64
+ if [ -t 0 ]; then
65
+ printf 'Project ID [%s]: ' "${SUGGESTED}"
66
+ IFS= read -r PROJECT_ID
67
+ fi
68
+ PROJECT_ID="${PROJECT_ID:-${SUGGESTED}}"
69
+ fi
70
+
71
+ if ! valid_id "${PROJECT_ID}"; then
72
+ echo "" >&2
73
+ echo "'${PROJECT_ID}' is not a valid project ID." >&2
74
+ echo "Rules: 6-30 characters, lowercase letters, digits and hyphens, starting with a" >&2
75
+ echo "letter and not ending with a hyphen." >&2
76
+ exit 2
77
+ fi
78
+
79
+ echo ""
80
+ echo "▶ Creating project"
81
+ echo " id: ${PROJECT_ID}"
82
+ echo " name: ${NAME}"
83
+ [ -n "${ORGANIZATION:-}" ] && echo " org: ${ORGANIZATION}"
84
+ [ -n "${FOLDER:-}" ] && echo " folder: ${FOLDER}"
85
+ echo ""
86
+
87
+ ARGS=(projects create "${PROJECT_ID}" --name "${NAME}")
88
+ [ -n "${ORGANIZATION:-}" ] && ARGS+=(--organization "${ORGANIZATION}")
89
+ [ -n "${FOLDER:-}" ] && ARGS+=(--folder "${FOLDER}")
90
+
91
+ if ! gcloud "${ARGS[@]}"; then
92
+ echo "" >&2
93
+ echo "Project creation failed. The usual cause is that the ID is already taken —" >&2
94
+ echo "globally, across all of Google Cloud, not just your organization. Re-run with" >&2
95
+ echo "a different one, e.g. --id ${PROJECT_ID}-$(od -An -N2 -tu2 </dev/urandom | tr -d ' ')" >&2
96
+ exit 1
97
+ fi
98
+
99
+ echo ""
100
+ echo "✔ Created ${PROJECT_ID}"
101
+ echo ""
102
+ echo "Next: link a billing account (nothing else works without it) —"
103
+ echo " $(dirname "$0")/link-billing.sh --project ${PROJECT_ID}"
104
+ echo ""
105
+ echo "And in step 3, this goes in your content repo's deployment.env:"
106
+ echo " PROJECT=${PROJECT_ID}"
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Roll a published engine image out to one deployment's Cloud Run service.
5
+ #
6
+ # The engine's CI publishes images to GHCR and stops there: choosing when to move to a
7
+ # new engine version is the deployment's call, not the engine repo's. This script is
8
+ # that call, and it is the same script for Marcus's instance and for a client's.
9
+ #
10
+ # Cloud Run can only pull from Artifact Registry, never from GHCR directly, so an image
11
+ # has to reach the deployment's own AR first. Two ways:
12
+ #
13
+ # 1. Mirror (default here): pull from GHCR, retag, push to AR. Needs docker locally,
14
+ # plus `gcloud auth configure-docker <region>-docker.pkg.dev` once, and — for a
15
+ # private GHCR package — `docker login ghcr.io` with a PAT that has read:packages.
16
+ # 2. AR remote repository: a one-time pull-through proxy of GHCR in the deployment's
17
+ # project, after which no local docker is involved. Then point --image straight at
18
+ # the proxied path and skip the mirror:
19
+ # gcloud artifacts repositories create ghcr-remote --repository-format=docker \
20
+ # --mode=remote-repository --location=<region> \
21
+ # --remote-docker-repo=https://ghcr.io # + upstream creds for a private package
22
+ #
23
+ # What it writes, and what it deliberately does not.
24
+ #
25
+ # The image and the **runtime shape** — scaling, CPU, memory, request timeout — are
26
+ # asserted on every rollout, from deployment.env (SCALING, CPU, MEMORY, MAX_INSTANCES,
27
+ # REQUEST_TIMEOUT, with the defaults in lib/deployment.sh). That is the point: a flag
28
+ # changed by hand in the console would otherwise survive every deploy silently, and this
29
+ # shape is not decoration — `scale-to-zero` only works because the service is driven from
30
+ # outside, so a service that quietly drifted back to always-on would run every cron twice.
31
+ #
32
+ # Env vars, secrets, volumes and the service account are NOT touched. Those are supplied
33
+ # by set-env.sh, set-secret.sh and setup-infrastructure.sh, and a wrong guess about a
34
+ # bucket name or a service account breaks a deployment far worse than drift does.
35
+ #
36
+ # A deployment driven by Cloud Scheduler also gets its triggers re-synced afterwards
37
+ # (scripts/setup-scheduler.sh): a deploy restarts the service, which is when new or renamed
38
+ # `cron:` registrations from the content repo take effect, so it is also when the Scheduler
39
+ # jobs mirroring them have to be brought up to date. An always-on deployment drives itself
40
+ # and is left alone.
41
+ #
42
+ # Usage:
43
+ # scripts/deploy.sh # the newest published engine (--tag latest)
44
+ # scripts/deploy.sh --tag sha-556ca72 # a specific build, e.g. to roll back
45
+ # scripts/deploy.sh --image europe-west1-docker.pkg.dev/PROJECT/REPO/agent:sha-556ca72
46
+ # scripts/deploy.sh --dry-run # print the target, image and shape; change nothing
47
+ # scripts/deploy.sh --no-scheduler # roll out without re-syncing the triggers
48
+ #
49
+ # Which deployment: ./deployment.env, or --config <file>. See deployment.env.example.
50
+
51
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
52
+
53
+ TAG="latest"
54
+ IMAGE=""
55
+ DRY_RUN=false
56
+ SYNC_SCHEDULER=true
57
+
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
61
+ --tag) TAG="${2:?--tag needs a value}"; shift 2 ;;
62
+ --image) IMAGE="${2:?--image needs a value}"; shift 2 ;;
63
+ --dry-run) DRY_RUN=true; shift ;;
64
+ --no-scheduler) SYNC_SCHEDULER=false; shift ;;
65
+ -h|--help) sed -n '3,49p' "$0"; exit 0 ;;
66
+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
67
+ esac
68
+ done
69
+
70
+ require_gcloud
71
+ resolve_deployment
72
+ announce_target "Deploying ${SERVICE}"
73
+
74
+ # --tag mirrors GHCR → this deployment's Artifact Registry; --image is taken as-is
75
+ # (already in AR, or reachable through an AR remote repository).
76
+ if [ -z "${IMAGE}" ]; then
77
+ SOURCE="${GHCR_IMAGE}:${TAG}"
78
+ IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${ARTIFACT_REPO}/${SERVICE}:${TAG}"
79
+ command -v docker >/dev/null || {
80
+ echo "docker is required to mirror ${SOURCE} into Artifact Registry." >&2
81
+ echo "Either install it, or set up an AR remote repository and pass --image." >&2
82
+ exit 1
83
+ }
84
+ echo "📥 Mirroring ${SOURCE} → ${IMAGE}"
85
+ if [ "${DRY_RUN}" = true ]; then
86
+ echo " (dry run: would pull, retag and push)"
87
+ else
88
+ if ! docker pull "${SOURCE}"; then
89
+ cat >&2 <<EOF
90
+
91
+ Could not pull ${SOURCE}.
92
+
93
+ The published engine image is public, so this needs no login — check the tag exists
94
+ and that the host can reach ghcr.io:
95
+
96
+ curl -sS "https://ghcr.io/token?scope=repository:meffecta/agent:pull&service=ghcr.io"
97
+
98
+ If you are pointing GHCR_IMAGE at a private package of your own instead, that one does
99
+ need a token with the read:packages scope, which the gh CLI does not request by default:
100
+
101
+ gh auth refresh -h github.com -s read:packages
102
+ gh auth token | docker login ghcr.io -u <your-github-username> --password-stdin
103
+ EOF
104
+ exit 1
105
+ fi
106
+ docker tag "${SOURCE}" "${IMAGE}"
107
+ if ! docker push "${IMAGE}"; then
108
+ cat >&2 <<EOF
109
+
110
+ Could not push to ${IMAGE}.
111
+
112
+ Artifact Registry needs docker configured for this region once:
113
+
114
+ gcloud auth configure-docker ${REGION}-docker.pkg.dev
115
+ EOF
116
+ exit 1
117
+ fi
118
+ fi
119
+ fi
120
+
121
+ # scale-to-zero is the default and what setup-scheduler.sh's triggers assume: no CPU
122
+ # except while a request is open, which is only correct because nothing in the service
123
+ # fires on its own. always-on is the other coherent pair, for a deployment that drives
124
+ # itself with in-process timers instead.
125
+ case "${SCALING}" in
126
+ scale-to-zero) SCALE_FLAGS=(--min-instances=0 --cpu-throttling --cpu-boost) ;;
127
+ always-on) SCALE_FLAGS=(--min-instances=1 --no-cpu-throttling) ;;
128
+ *)
129
+ echo "Unknown SCALING=\"${SCALING}\" — use scale-to-zero or always-on." >&2
130
+ exit 2
131
+ ;;
132
+ esac
133
+
134
+ echo "🧩 Rolling out ${IMAGE}"
135
+ echo " shape: ${SCALING}, cpu ${CPU}, memory ${MEMORY}, max ${MAX_INSTANCES} instance(s), timeout ${REQUEST_TIMEOUT}s"
136
+ if [ "${DRY_RUN}" = true ]; then
137
+ echo " (dry run: nothing was changed)"
138
+ exit 0
139
+ fi
140
+ gcloud run deploy "${SERVICE}" \
141
+ --project "${PROJECT}" \
142
+ --region "${REGION}" \
143
+ --platform managed \
144
+ --image "${IMAGE}" \
145
+ --cpu "${CPU}" \
146
+ --memory "${MEMORY}" \
147
+ --max-instances "${MAX_INSTANCES}" \
148
+ --timeout "${REQUEST_TIMEOUT}" \
149
+ --execution-environment gen2 \
150
+ "${SCALE_FLAGS[@]}"
151
+
152
+ # Only for a deployment that has opted into external triggers — AGENT_TASKS_QUEUE on the
153
+ # service is what says so. Creating Scheduler jobs for an always-on one would mean every
154
+ # cron fires twice, once from outside and once from the in-process timer.
155
+ if [ "${SYNC_SCHEDULER}" = true ] &&
156
+ gcloud run services describe "${SERVICE}" --project "${PROJECT}" --region "${REGION}" \
157
+ --format json 2>/dev/null | grep -q AGENT_TASKS_QUEUE; then
158
+ echo ""
159
+ "$(dirname "${BASH_SOURCE[0]}")/setup-scheduler.sh" ${CONFIG_FILE:+--config "${CONFIG_FILE}"}
160
+ fi
161
+
162
+ URL=$(gcloud run services describe "${SERVICE}" \
163
+ --project "${PROJECT}" --region "${REGION}" \
164
+ --format 'value(status.url)' 2>/dev/null || true)
165
+ if [ -n "${URL}" ]; then
166
+ echo "🌍 ${SERVICE} → ${URL}"
167
+ echo " Liveness: curl -fsS ${URL}/health"
168
+ echo " Ask it: ${URL}/ask (any username, AGENT_WEBHOOK_SECRET as the password)"
169
+ fi
@@ -0,0 +1,109 @@
1
+ # Shared configuration for the operator scripts. Sourced, never run.
2
+ #
3
+ # One deployment = one config file, and that file belongs in the deployment's CONTENT
4
+ # repo, committed: which GCP project and service a set of jobs runs as is a fact about
5
+ # that deployment, not about the engine. None of it is secret (secrets live in Secret
6
+ # Manager), so it is checked in, and a fresh clone of the content repo is enough to
7
+ # operate the deployment.
8
+ #
9
+ # So the usual shape is: cd to the content repo, run the engine's scripts from there.
10
+ #
11
+ # cd ~/work/acme-agent-home && ~/work/agent/scripts/deploy.sh
12
+ #
13
+ # Resolution order, first hit wins per value:
14
+ # 1. a variable already set in the environment (one-off override)
15
+ # 2. the config file: --config <file>, else $DEPLOYMENT_CONFIG, else ./deployment.env
16
+ # in the current directory
17
+ #
18
+ # Nothing is guessed beyond that: every script prints the project and service it is about
19
+ # to touch and refuses to run without them, so being in the wrong directory is an error
20
+ # rather than a command against the wrong deployment.
21
+ #
22
+ # Config file format is plain KEY=value lines, # comments allowed. See
23
+ # deployment.env.example.
24
+
25
+ # Consume --config <file> from a script's arguments before it parses its own.
26
+ # Usage: eval "$(extract_config_flag "$@")" — sets CONFIG_FILE and rewrites "$@".
27
+ CONFIG_FILE=""
28
+
29
+ load_deployment_config() {
30
+ local file="${CONFIG_FILE:-${DEPLOYMENT_CONFIG:-}}"
31
+ if [ -z "${file}" ]; then
32
+ file="${PWD}/deployment.env" # i.e. the content repo you are standing in
33
+ fi
34
+ if [ ! -f "${file}" ]; then
35
+ if [ -n "${CONFIG_FILE}" ] || [ -n "${DEPLOYMENT_CONFIG:-}" ]; then
36
+ echo "Config file not found: ${file}" >&2
37
+ exit 1
38
+ fi
39
+ return 0 # no config file is fine as long as the environment supplies the values
40
+ fi
41
+ DEPLOYMENT_CONFIG_USED="${file}"
42
+ local key value
43
+ while IFS='=' read -r key value || [ -n "${key}" ]; do
44
+ key="${key%%#*}" # drop trailing comment on the key side
45
+ key="$(printf '%s' "${key}" | tr -d '[:space:]')"
46
+ [ -z "${key}" ] && continue
47
+ value="${value%$'\r'}" # tolerate CRLF
48
+ value="${value#"${value%%[![:space:]]*}"}" # trim leading space
49
+ value="${value%"${value##*[![:space:]]}"}" # trim trailing space
50
+ value="${value%\"}"; value="${value#\"}" # strip surrounding quotes
51
+ value="${value%\'}"; value="${value#\'}"
52
+ # An environment variable already set wins — that is the one-off override.
53
+ if [ -z "$(eval "printf '%s' \"\${${key}:-}\"" 2>/dev/null)" ]; then
54
+ eval "${key}=\$value"
55
+ fi
56
+ done <"${file}"
57
+ }
58
+
59
+ # Fill in what the config file did not, then insist on the two that must never be guessed.
60
+ resolve_deployment() {
61
+ load_deployment_config
62
+ REGION="${REGION:-europe-west1}"
63
+ ARTIFACT_REPO="${ARTIFACT_REPO:-${SERVICE:-agent}-images}"
64
+ GHCR_IMAGE="${GHCR_IMAGE:-ghcr.io/meffecta/agent}"
65
+ TIMEZONE="${TIMEZONE:-Europe/Stockholm}"
66
+
67
+ # The runtime shape, asserted by deploy.sh on every rollout so the deployed service
68
+ # matches this file rather than whatever was last done by hand in the console. Defaults
69
+ # are the shape setup-infrastructure.sh provisions; override any of them per deployment.
70
+ SCALING="${SCALING:-scale-to-zero}"
71
+ CPU="${CPU:-2}"
72
+ MEMORY="${MEMORY:-4Gi}"
73
+ MAX_INSTANCES="${MAX_INSTANCES:-1}"
74
+ REQUEST_TIMEOUT="${REQUEST_TIMEOUT:-3600}"
75
+
76
+ local missing=""
77
+ [ -z "${PROJECT:-}" ] && missing="${missing} PROJECT"
78
+ [ -z "${SERVICE:-}" ] && missing="${missing} SERVICE"
79
+ if [ -n "${missing}" ]; then
80
+ cat >&2 <<EOF
81
+ Missing required setting(s):${missing}
82
+
83
+ No deployment.env found in $(pwd).
84
+
85
+ That file lives in the deployment's content repo (committed — none of it is
86
+ secret), so the usual fix is to run this from there:
87
+
88
+ cd /path/to/your-content-repo && ${0}
89
+
90
+ Creating one: copy scripts/deployment.env.example beside your jobs/. Or point
91
+ at a file explicitly with --config /path/to/deployment.env.
92
+ EOF
93
+ exit 2
94
+ fi
95
+ }
96
+
97
+ # Every script says what it is about to touch, before it touches it.
98
+ announce_target() {
99
+ local action="$1"
100
+ echo "▶ ${action}"
101
+ echo " project: ${PROJECT}"
102
+ echo " service: ${SERVICE} (${REGION})"
103
+ [ -n "${DEPLOYMENT_CONFIG_USED:-}" ] && echo " config: ${DEPLOYMENT_CONFIG_USED}"
104
+ echo ""
105
+ }
106
+
107
+ require_gcloud() {
108
+ command -v gcloud >/dev/null || { echo "gcloud is required." >&2; exit 1; }
109
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Step 2 of IMPLEMENTATION.md: link a billing account to the project.
5
+ #
6
+ # Nothing else works without this — provisioning starts by enabling APIs, which fails on
7
+ # an unbilled project, and the failure does not mention billing.
8
+ #
9
+ # Lists the billing accounts you can use, lets you pick one, links it, and verifies the
10
+ # result. Closed accounts are shown but cannot be selected: linking one silently leaves
11
+ # the project unbilled.
12
+ #
13
+ # The project comes from --project, else PROJECT in ./deployment.env or the environment,
14
+ # else a prompt.
15
+ #
16
+ # Usage:
17
+ # scripts/link-billing.sh
18
+ # scripts/link-billing.sh --project acme-agent-506513
19
+ # scripts/link-billing.sh --project acme-agent-506513 --billing-account 0X0X0X-...
20
+
21
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
22
+
23
+ BILLING_ACCOUNT=""
24
+
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ --config) CONFIG_FILE="${2:?--config needs a file}"; shift 2 ;;
28
+ --project) PROJECT="${2:?--project needs a value}"; shift 2 ;;
29
+ --billing-account) BILLING_ACCOUNT="${2:?--billing-account needs a value}"; shift 2 ;;
30
+ -h|--help) sed -n '4,20p' "$0"; exit 0 ;;
31
+ *) echo "Unknown argument: $1" >&2; exit 2 ;;
32
+ esac
33
+ done
34
+
35
+ require_gcloud
36
+ # Soft load: at this point in the set-up a content repo may not exist yet, so a missing
37
+ # deployment.env is normal — unlike the later scripts, this one only needs PROJECT.
38
+ load_deployment_config
39
+
40
+ if [ -z "${PROJECT:-}" ]; then
41
+ if [ ! -t 0 ]; then
42
+ echo "No project given. Use --project, or run from a content repo with deployment.env." >&2
43
+ exit 2
44
+ fi
45
+ printf 'Project ID: '
46
+ IFS= read -r PROJECT
47
+ fi
48
+ [ -n "${PROJECT}" ] || { echo "A project ID is required." >&2; exit 2; }
49
+
50
+ gcloud projects describe "${PROJECT}" --format='value(projectId)' >/dev/null 2>&1 || {
51
+ echo "Cannot see project '${PROJECT}' — check the ID with: gcloud projects list" >&2
52
+ exit 1
53
+ }
54
+
55
+ CURRENT=$(gcloud billing projects describe "${PROJECT}" --format='value(billingEnabled)' 2>/dev/null || echo "")
56
+ if [ "${CURRENT}" = "True" ]; then
57
+ ACCOUNT=$(gcloud billing projects describe "${PROJECT}" --format='value(billingAccountName)' 2>/dev/null || echo "")
58
+ echo "✔ ${PROJECT} already has billing enabled (${ACCOUNT#billingAccounts/})."
59
+ exit 0
60
+ fi
61
+
62
+ if [ -z "${BILLING_ACCOUNT}" ]; then
63
+ echo "Billing accounts you can use:"
64
+ echo ""
65
+ IDS=()
66
+ INDEX=0
67
+ # displayName can contain spaces, so split on tabs rather than whitespace.
68
+ while IFS=$'\t' read -r id display open; do
69
+ [ -n "${id}" ] || continue
70
+ if [ "${open}" = "True" ]; then
71
+ INDEX=$((INDEX + 1))
72
+ IDS+=("${id}")
73
+ printf ' %d) %-24s %s\n' "${INDEX}" "${id}" "${display}"
74
+ else
75
+ printf ' %-24s %s (closed — cannot be linked)\n' "${id}" "${display}"
76
+ fi
77
+ done < <(gcloud billing accounts list --format='value[separator=" "](name.basename(),displayName,open)' 2>/dev/null)
78
+
79
+ if [ ${#IDS[@]} -eq 0 ]; then
80
+ echo " (none open)" >&2
81
+ echo "" >&2
82
+ echo "No open billing account is available to this login. Create one at" >&2
83
+ echo "https://console.cloud.google.com/billing, or ask whoever administers billing" >&2
84
+ echo "to grant you Billing Account User on theirs." >&2
85
+ exit 1
86
+ fi
87
+
88
+ echo ""
89
+ if [ ${#IDS[@]} -eq 1 ]; then
90
+ BILLING_ACCOUNT="${IDS[0]}"
91
+ echo "Only one open account — using ${BILLING_ACCOUNT}."
92
+ else
93
+ if [ ! -t 0 ]; then
94
+ echo "Several accounts available; pass --billing-account <id>." >&2
95
+ exit 2
96
+ fi
97
+ printf 'Select [1-%d]: ' "${#IDS[@]}"
98
+ IFS= read -r CHOICE
99
+ case "${CHOICE}" in
100
+ ''|*[!0-9]*) echo "Not a number: '${CHOICE}'" >&2; exit 2 ;;
101
+ esac
102
+ if [ "${CHOICE}" -lt 1 ] || [ "${CHOICE}" -gt ${#IDS[@]} ]; then
103
+ echo "Out of range: ${CHOICE}" >&2
104
+ exit 2
105
+ fi
106
+ BILLING_ACCOUNT="${IDS[$((CHOICE - 1))]}"
107
+ fi
108
+ fi
109
+
110
+ echo ""
111
+ echo "▶ Linking ${BILLING_ACCOUNT} to ${PROJECT}"
112
+ LINK_ERR=$(mktemp)
113
+ trap 'rm -f "${LINK_ERR}"' EXIT
114
+ if ! gcloud billing projects link "${PROJECT}" --billing-account="${BILLING_ACCOUNT}" >/dev/null 2>"${LINK_ERR}"; then
115
+ cat "${LINK_ERR}" >&2
116
+ if grep -qi 'quota' "${LINK_ERR}"; then
117
+ cat >&2 <<EOF
118
+
119
+ That is the billing account's project quota, not a problem with this project: a
120
+ billing account may only fund so many projects, and yours is full. Either free a
121
+ slot —
122
+
123
+ gcloud projects list # find one you no longer need
124
+ gcloud billing projects unlink <PROJECT> # or delete the project outright
125
+
126
+ — or request an increase at
127
+ https://support.google.com/code/contact/billing_quota_increase
128
+ EOF
129
+ fi
130
+ exit 1
131
+ fi
132
+
133
+ # Linking can report success while leaving the project unbilled (a closed account), so
134
+ # confirm the state rather than the command's exit code.
135
+ if [ "$(gcloud billing projects describe "${PROJECT}" --format='value(billingEnabled)' 2>/dev/null)" = "True" ]; then
136
+ echo "✔ billingEnabled: true"
137
+ echo ""
138
+ echo "Next: create the content repo (step 3), then provision from inside it —"
139
+ echo " $(dirname "$0")/setup-infrastructure.sh"
140
+ else
141
+ echo "✖ Linked, but billingEnabled is still false — check the account is open and funded." >&2
142
+ exit 1
143
+ fi
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ // One-time, local: mint the OAuth refresh token the agent uses for the private
3
+ // Gmail account. Opens a browser consent flow via the loopback redirect and
4
+ // prints the refresh token — store it as the GMAIL_REFRESH_TOKEN secret.
5
+ //
6
+ // Prerequisites (once, in the agent's GCP project):
7
+ // 1. Enable the Gmail API and Google Calendar API.
8
+ // 2. OAuth consent screen: External, then PUBLISH TO "In production" —
9
+ // an app left in "Testing" expires refresh tokens after 7 days.
10
+ // 3. Create an OAuth client of type "Desktop app"; note client id + secret.
11
+ //
12
+ // Usage:
13
+ // node scripts/mint-gmail-token.mjs --client-id <id> --client-secret <secret>
14
+ // [--account <NAME>] [--readonly]
15
+ //
16
+ // Sign in as WHICHEVER account the token is for when the browser opens — run once
17
+ // per account (private Gmail → GMAIL_REFRESH_TOKEN; additional accounts →
18
+ // --account NAME → GMAIL_<NAME>_REFRESH_TOKEN). --readonly mints read scopes only,
19
+ // which is the right level for an account Marcus doesn't administer alone (a
20
+ // company mailbox: read the mail, never send from that identity).
21
+ // Gmail scopes are "restricted", so an unverified app
22
+ // shows a warning — Advanced → continue is fine for your own account. A Workspace
23
+ // account you don't control only works if that tenant's admin policy allows
24
+ // third-party OAuth apps; a refusal at consent means their admin has to allowlist
25
+ // this client id.
26
+
27
+ import { createServer } from "node:http";
28
+ import { parseArgs } from "node:util";
29
+
30
+ const { values: args } = parseArgs({
31
+ options: {
32
+ "client-id": { type: "string" },
33
+ "client-secret": { type: "string" },
34
+ account: { type: "string" },
35
+ readonly: { type: "boolean", default: false },
36
+ },
37
+ });
38
+
39
+ const accountName = args.account?.toUpperCase();
40
+ if (accountName && !/^[A-Z0-9]+$/.test(accountName)) {
41
+ console.error("--account must be a single alphanumeric word (it becomes GMAIL_<NAME>_REFRESH_TOKEN)");
42
+ process.exit(1);
43
+ }
44
+ const tokenVar = accountName ? `GMAIL_${accountName}_REFRESH_TOKEN` : "GMAIL_REFRESH_TOKEN";
45
+
46
+ const clientId = args["client-id"] ?? process.env.GMAIL_CLIENT_ID;
47
+ const clientSecret = args["client-secret"] ?? process.env.GMAIL_CLIENT_SECRET;
48
+ if (!clientId || !clientSecret) {
49
+ console.error("usage: mint-gmail-token.mjs --client-id <id> --client-secret <secret>");
50
+ process.exit(1);
51
+ }
52
+
53
+ const READ_SCOPES = [
54
+ "https://www.googleapis.com/auth/gmail.readonly",
55
+ "https://www.googleapis.com/auth/calendar.readonly",
56
+ "https://www.googleapis.com/auth/drive.readonly",
57
+ ];
58
+ const SCOPES = args.readonly
59
+ ? READ_SCOPES
60
+ : [
61
+ "https://www.googleapis.com/auth/gmail.readonly",
62
+ "https://www.googleapis.com/auth/gmail.send",
63
+ "https://www.googleapis.com/auth/gmail.compose",
64
+ "https://www.googleapis.com/auth/calendar",
65
+ "https://www.googleapis.com/auth/drive",
66
+ "https://www.googleapis.com/auth/spreadsheets",
67
+ "https://www.googleapis.com/auth/documents",
68
+ "https://www.googleapis.com/auth/presentations",
69
+ ];
70
+
71
+ const server = createServer();
72
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
73
+ const redirectUri = `http://127.0.0.1:${server.address().port}`;
74
+
75
+ const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
76
+ authUrl.search = new URLSearchParams({
77
+ client_id: clientId,
78
+ redirect_uri: redirectUri,
79
+ response_type: "code",
80
+ scope: SCOPES.join(" "),
81
+ access_type: "offline",
82
+ // Force a fresh consent so Google returns a refresh token even if one was granted before.
83
+ prompt: "consent",
84
+ }).toString();
85
+
86
+ console.error(
87
+ `Open this URL in a browser and sign in as ${accountName ? `the ${accountName} account` : "the private account"}` +
88
+ ` (scopes: ${args.readonly ? "read-only" : "full"}):\n`,
89
+ );
90
+ console.error(authUrl.toString());
91
+ console.error("\nWaiting for the redirect...");
92
+
93
+ const code = await new Promise((resolve, reject) => {
94
+ server.on("request", (req, res) => {
95
+ const url = new URL(req.url, redirectUri);
96
+ const err = url.searchParams.get("error");
97
+ const c = url.searchParams.get("code");
98
+ res.writeHead(200, { "content-type": "text/html" });
99
+ res.end(err ? `<h3>Failed: ${err}</h3>` : "<h3>Done — you can close this tab.</h3>");
100
+ if (err) reject(new Error(err));
101
+ else if (c) resolve(c);
102
+ });
103
+ });
104
+ server.close();
105
+
106
+ const res = await fetch("https://oauth2.googleapis.com/token", {
107
+ method: "POST",
108
+ headers: { "content-type": "application/x-www-form-urlencoded" },
109
+ body: new URLSearchParams({
110
+ client_id: clientId,
111
+ client_secret: clientSecret,
112
+ code,
113
+ grant_type: "authorization_code",
114
+ redirect_uri: redirectUri,
115
+ }),
116
+ });
117
+ const json = await res.json();
118
+ if (!res.ok || !json.refresh_token) {
119
+ console.error(`token exchange failed (${res.status}): ${JSON.stringify(json)}`);
120
+ process.exit(1);
121
+ }
122
+
123
+ // Refresh token to stdout (pipe straight into gcloud), everything else to stderr.
124
+ console.error(`\nRefresh token minted — it is ${tokenVar}. Store it without it touching your shell history:`);
125
+ console.error(` node scripts/mint-gmail-token.mjs ... | gcloud secrets create ${tokenVar} --data-file=-\n`);
126
+ console.log(json.refresh_token);