From 00d86b47a3bb67018801b02718a0497edb4aa026 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Mon, 29 Jun 2026 23:48:47 +0200 Subject: [PATCH] feat(skills,cli): promote-to-prod replay (ADR-0003 capstone) + supplier payment fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The human-gated path that carries a reviewed sandbox change to prod. - promote-plan.sh: render a manifest (JSON array of write ops with symbolic @refs instead of ids — portable sandbox->prod) as a human-readable change-set. - promote-apply.sh --target sandbox|prod: replay it, resolving each @ref to the id actually created during the run (dependent ops wire up). sandbox rehearses via dol-write.sh; prod via dol-prod-write.sh. - dol-prod-write.sh: the ONLY prod-write path. Prod key read from the ENVIRONMENT only (DOLIBARR_PROD_WRITE_KEY, never a stored .env); every write refused unless ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD. - create scripts take a DOL_WRITE override so promote-apply reuses them per target. - bin/arcodange: `promote {plan|apply}` group + example manifest. - payment-record.sh: fixed supplier payments (payment_mode_id + closepaidinvoices). Proven live: plan renders; apply --target sandbox replays a 3-op chain with refs resolved (@tp1->id, invoice socid=@tp1, payment invoice=@inv1); --target prod without the confirm flag is REFUSED before sending. Supplier payment now works end-to-end via the script. Limitation (documented): manifests reference entities they create (@ref); pre-existing prod entities need business-key resolution (follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/dolibarr-sandbox-write/SKILL.md | 39 +++++++++-- .../examples/promote-manifest.json | 12 ++++ .../scripts/creditnote-create.sh | 2 +- .../scripts/dol-prod-write.sh | 39 +++++++++++ .../scripts/invoice-create.sh | 2 +- .../scripts/payment-record.sh | 5 +- .../scripts/promote-apply.sh | 68 +++++++++++++++++++ .../scripts/promote-plan.sh | 40 +++++++++++ .../scripts/thirdparty-create.sh | 2 +- bin/arcodange | 29 ++++++++ 10 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/dolibarr-sandbox-write/examples/promote-manifest.json create mode 100755 .claude/skills/dolibarr-sandbox-write/scripts/dol-prod-write.sh create mode 100755 .claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh create mode 100755 .claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh diff --git a/.claude/skills/dolibarr-sandbox-write/SKILL.md b/.claude/skills/dolibarr-sandbox-write/SKILL.md index 178d3ba..1666452 100644 --- a/.claude/skills/dolibarr-sandbox-write/SKILL.md +++ b/.claude/skills/dolibarr-sandbox-write/SKILL.md @@ -38,10 +38,12 @@ human promotes the reviewed change to prod. key is read-only and lives in a different skill's `.env`. - **Resettable.** Anything written here is wiped by `ops/sandbox/sandbox-lifecycle.sh refresh-from-prod`, so mistakes cost a reset, not data. -- **Promotion to prod is NOT in this skill.** Rehearse here → capture a reviewable - diff with the read-only `dolibarr-data-snapshot` skill (before/after) → a human - approves → the same operations are replayed against prod under a separate, - human-held prod-write credential. Never wire a prod-write key into this skill. +- **Promotion to prod is gated, not automatic.** Rehearse here → review the + change-set (`promote-plan.sh`) → replay it on prod (`promote-apply.sh --target + prod`). The prod write key is supplied via the **environment at apply time** + (`DOLIBARR_PROD_WRITE_KEY`), never stored in any `.env`, and `dol-prod-write.sh` + refuses every prod write unless `ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD`. + See "Promote to prod" below. ## Setup @@ -115,6 +117,35 @@ A customer invoice of `type=2` referencing `source_invoice` (`fk_facture_source` amounts come out negative (a credit). `validate:true` turns the draft into a numbered `AVC…` avoir. Emits `{id, ref, total_ht, total_ttc, fk_facture_source, statut}`. +## Promote to prod (rehearse → review → replay) + +The ADR-0003 capstone: take a change rehearsed in the sandbox and apply the **same +operations** to prod, with a human in the loop. The unit is a **manifest** — a JSON +array of write ops using **symbolic refs** (`@name`) instead of ids, so it is +portable from sandbox to prod (an invoice references `@tp1`, the thirdparty created +earlier in the run). See `examples/promote-manifest.json`. + +```sh +scripts/promote-plan.sh change.json # 1. human-readable review +scripts/promote-apply.sh change.json --target sandbox # 2. rehearse the replay (safe) +# 3. promote — writes PROD; the key is env-only and the confirm flag is mandatory: +DOLIBARR_PROD_WRITE_KEY="" \ +ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \ + scripts/promote-apply.sh change.json --target prod +``` + +`promote-apply` resolves each `@ref` to the id actually created during the run, so +dependent ops wire up on the target. `--target sandbox` writes via `dol-write.sh`; +`--target prod` writes via `dol-prod-write.sh`, which reads `DOLIBARR_PROD_WRITE_KEY` +**from the environment only** (never a stored `.env`) and refuses any write unless +`ARCO_PROMOTE_CONFIRM` is set exactly. Pair it with `dolibarr-data-snapshot` (prod +before/after) to confirm only the intended records changed. + +Limitation: a manifest references entities it **creates** (via `@ref`). Referencing +a *pre-existing* prod entity by a sandbox id won't match — resolve those by business +key (name/code/ref) first. Self-contained change-sets (new thirdparty + its invoice ++ payment) replay cleanly today. + ## Gotchas - **Validate before paying.** A draft (`statut=0`, ref `PROV…`) cannot be paid. diff --git a/.claude/skills/dolibarr-sandbox-write/examples/promote-manifest.json b/.claude/skills/dolibarr-sandbox-write/examples/promote-manifest.json new file mode 100644 index 0000000..1f387fc --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/examples/promote-manifest.json @@ -0,0 +1,12 @@ +[ + { "op": "thirdparty", "ref": "tp1", + "input": { "name": "ACME Conseil", "role": "client", "tva_intra": "FR..." } }, + + { "op": "invoice", "ref": "inv1", + "input": { "socid": "@tp1", "kind": "customer", "validate": true, + "lines": [ { "desc": "Prestation conseil", "qty": 2, "price_ht": 500, "tva": 20, "type": "service" }, + { "desc": "Licence annuelle", "qty": 1, "price_ht": 100, "tva": 20, "type": "product" } ] } }, + + { "op": "payment", + "input": { "invoice_id": "@inv1", "mode": "VIR", "account_id": 1, "comment": "Acompte" } } +] diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/creditnote-create.sh b/.claude/skills/dolibarr-sandbox-write/scripts/creditnote-create.sh index 8c8444f..98ce9c9 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/creditnote-create.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/creditnote-create.sh @@ -13,7 +13,7 @@ # Emits {id, ref, total_ht, total_ttc, fk_facture_source, statut} on stdout. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -W="${SCRIPT_DIR}/dol-write.sh" +W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" SRC="${1:-}" if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/dol-prod-write.sh b/.claude/skills/dolibarr-sandbox-write/scripts/dol-prod-write.sh new file mode 100755 index 0000000..d41c716 --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/scripts/dol-prod-write.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# GATED prod-write wrapper — the ONLY path in this skill that writes PRODUCTION. +# +# Used solely by promote-apply.sh --target prod, at promotion time. Two deliberate +# frictions make accidental or autonomous prod writes impossible (ADR-0003): +# 1. The prod write key is read from the ENVIRONMENT (DOLIBARR_PROD_WRITE_KEY), +# never from a stored .env — a human must supply it per invocation. +# 2. Any write method (POST/PUT/DELETE/PATCH) is REFUSED unless ARCO_PROMOTE_CONFIRM +# equals exactly "I-UNDERSTAND-THIS-WRITES-PROD". +# GET is allowed (id read-back) but still needs the key. +# +# DOLIBARR_PROD_WRITE_KEY=… ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \ +# dol-prod-write.sh POST /thirdparties '{...}' +set -euo pipefail +PROD_URL="${DOLIBARR_PROD_URL:-https://erp.arcodange.lab}" +: "${DOLIBARR_PROD_WRITE_KEY:?dol-prod-write.sh: DOLIBARR_PROD_WRITE_KEY must be supplied in the environment (never stored)}" + +if [[ $# -lt 2 ]]; then echo "usage: dol-prod-write.sh [body]" >&2; exit 2; fi +METHOD="$1"; API_PATH="$2"; BODY="${3-}" +case "${METHOD}" in + GET) ;; + POST|PUT|DELETE|PATCH) + if [[ "${ARCO_PROMOTE_CONFIRM:-}" != "I-UNDERSTAND-THIS-WRITES-PROD" ]]; then + echo "dol-prod-write.sh: REFUSING ${METHOD} ${API_PATH} on PRODUCTION (${PROD_URL})." >&2 + echo " Set ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD to authorize prod writes." >&2 + exit 3 + fi ;; + *) echo "dol-prod-write.sh: unsupported method '${METHOD}'" >&2; exit 2 ;; +esac + +CURL_ARGS=( -sS -X "${METHOD}" -H "DOLAPIKEY: ${DOLIBARR_PROD_WRITE_KEY}" -H "Accept: application/json" --max-time 30 ) +[[ -n "${BODY}" ]] && CURL_ARGS+=( -H "Content-Type: application/json" --data "${BODY}" ) + +BODY_FILE="$(mktemp -t dolprod.XXXXXX)"; trap 'rm -f "${BODY_FILE}"' EXIT +HTTP_CODE=$(curl "${CURL_ARGS[@]}" -o "${BODY_FILE}" -w "%{http_code}" "${PROD_URL}/api/index.php${API_PATH}") +cat "${BODY_FILE}" +if [[ "${HTTP_CODE}" -ge 400 ]]; then + echo "" >&2; echo "dol-prod-write.sh: HTTP ${HTTP_CODE} on ${METHOD} ${API_PATH}" >&2; exit 1 +fi diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh b/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh index 13b0639..ad80396 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/invoice-create.sh @@ -13,7 +13,7 @@ # Emits {id, ref, ref_supplier, total_ht, total_ttc, statut} on stdout. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -W="${SCRIPT_DIR}/dol-write.sh" +W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" SRC="${1:-}" if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/payment-record.sh b/.claude/skills/dolibarr-sandbox-write/scripts/payment-record.sh index 9ea1397..447e8c0 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/payment-record.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/payment-record.sh @@ -14,7 +14,7 @@ # Emits the new payment id on stdout. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -W="${SCRIPT_DIR}/dol-write.sh" +W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" SRC="${1:-}" if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi @@ -41,7 +41,8 @@ if supplier: if d.get("amount") is None: sys.exit("payment-record.sh: supplier payments require an 'amount'") endpoint = "/supplierinvoices/%s/payments" % inv - body = {"datepaye": epoch, "paymentid": mode, "accountid": d["account_id"], + body = {"datepaye": epoch, "payment_mode_id": mode, "closepaidinvoices": "yes", + "accountid": d["account_id"], "amount": str(d["amount"]), "num_payment": d.get("num", ""), "comment": d.get("comment", "")} else: diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh b/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh new file mode 100755 index 0000000..4048fe0 --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Replay a promote manifest against a target — sandbox (rehearsal) or prod (real). +# +# Resolves the manifest's symbolic @refs to the ids actually created during this +# run, so dependent ops (an invoice -> its just-created thirdparty) wire up on the +# target. Sandbox uses dol-write.sh; prod uses the gated dol-prod-write.sh. +# +# promote-apply.sh [--target sandbox|prod] +# +# --target prod requires, in the environment (never stored): +# DOLIBARR_PROD_WRITE_KEY= +# ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANIFEST="${1:?usage: promote-apply.sh [--target sandbox|prod]}"; shift || true +TARGET="sandbox" +while [[ $# -gt 0 ]]; do + case "$1" in + --target) TARGET="${2:?}"; shift 2 ;; + *) echo "promote-apply.sh: unknown arg '$1'" >&2; exit 2 ;; + esac +done +case "${TARGET}" in + sandbox) export DOL_WRITE="${SCRIPT_DIR}/dol-write.sh" ;; + prod) export DOL_WRITE="${SCRIPT_DIR}/dol-prod-write.sh" ;; + *) echo "promote-apply.sh: --target must be sandbox|prod" >&2; exit 2 ;; +esac +echo ">>> promote-apply target=${TARGET} (writes via $(basename "${DOL_WRITE}"))" >&2 + +python3 - "$MANIFEST" "$SCRIPT_DIR" <<'PY' +import json, sys, subprocess, os +manifest_path, script_dir = sys.argv[1], sys.argv[2] +ops = json.load(open(manifest_path)) +OP_SCRIPT = {"thirdparty": "thirdparty-create.sh", "invoice": "invoice-create.sh", + "creditnote": "creditnote-create.sh", "payment": "payment-record.sh"} +refmap = {} +def resolve(v): + if isinstance(v, str) and v.startswith("@"): + k = v[1:] + if k not in refmap: + sys.exit("promote-apply: unresolved ref @%s (is it created earlier in the manifest?)" % k) + return refmap[k] + if isinstance(v, dict): + return {kk: resolve(vv) for kk, vv in v.items()} + if isinstance(v, list): + return [resolve(x) for x in v] + return v +for i, op in enumerate(ops, 1): + t = op["op"]; script = OP_SCRIPT.get(t) + if not script: + sys.exit("promote-apply: unknown op '%s'" % t) + inp = resolve(op.get("input", {})) + r = subprocess.run([os.path.join(script_dir, script)], input=json.dumps(inp), + capture_output=True, text=True, env=os.environ) + if r.returncode != 0: + sys.stderr.write(r.stdout + r.stderr + "\n") + sys.exit("promote-apply: op %d (%s) FAILED" % (i, t)) + out = r.stdout.strip() + try: + rid = json.loads(out).get("id") + except Exception: + rid = out if out.isdigit() else None + ref = op.get("ref") + if ref and rid is not None: + refmap[ref] = int(rid) if str(rid).isdigit() else rid + print(" [%d/%d] %-11s %-8s -> id=%s" % (i, len(ops), t, ("@" + ref) if ref else "", rid)) +print("OK — promote complete. ref -> id: %s" % json.dumps(refmap)) +PY diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh b/.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh new file mode 100755 index 0000000..77ff223 --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Render a promote manifest as a human-readable change-set for review. +# +# A manifest is a JSON array of operations using SYMBOLIC refs (@name) instead of +# ids, so the SAME manifest replays on sandbox or prod: +# [ {"op":"thirdparty","ref":"tp1","input":{"name":"OVH","role":"supplier"}}, +# {"op":"invoice","ref":"inv1","input":{"socid":"@tp1","kind":"supplier", +# "ref_supplier":"X","validate":true,"lines":[{"desc":"Hosting","qty":1,"price_ht":80,"tva":20,"type":"service"}]}}, +# {"op":"payment","input":{"invoice_id":"@inv1","mode":"VIR","account_id":1}} ] +set -euo pipefail +MANIFEST="${1:?usage: promote-plan.sh }" +python3 - "$MANIFEST" <<'PY' +import json, sys +ops = json.load(open(sys.argv[1])) +print("Promote plan — %d operation(s) (symbolic refs resolve at apply time):\n" % len(ops)) +for i, op in enumerate(ops, 1): + t = op["op"]; inp = op.get("input", {}); ref = op.get("ref") + print(" %d. %s%s" % (i, t, (" => @%s" % ref) if ref else "")) + if t == "thirdparty": + print(" name=%r role=%s%s" % (inp.get("name"), inp.get("role", "client"), + (" tva=%s" % inp["tva_intra"]) if inp.get("tva_intra") else "")) + elif t == "invoice": + print(" socid=%s kind=%s%s validate=%s" % (inp.get("socid"), inp.get("kind", "customer"), + (" ref_supplier=%s" % inp["ref_supplier"]) if inp.get("ref_supplier") else "", bool(inp.get("validate")))) + for ln in inp.get("lines", []): + print(" - %r qty=%s pu_ht=%s tva=%s%% [%s]" % (ln.get("desc", ""), ln.get("qty", 1), + ln.get("price_ht", ln.get("subprice")), ln.get("tva", ln.get("tva_tx", 20)), ln.get("type", "service"))) + elif t == "creditnote": + print(" socid=%s source_invoice=%s validate=%s" % (inp.get("socid"), inp.get("source_invoice"), bool(inp.get("validate")))) + for ln in inp.get("lines", []): + print(" - %r qty=%s pu_ht=%s tva=%s%%" % (ln.get("desc", ""), ln.get("qty", 1), + ln.get("price_ht", ln.get("subprice")), ln.get("tva", ln.get("tva_tx", 20)))) + elif t == "payment": + print(" invoice=%s mode=%s account=%s %s" % (inp.get("invoice_id"), inp.get("mode", "VIR"), + inp.get("account_id"), ("amount=%s" % inp["amount"]) if inp.get("amount") else "(full)")) +print("\nNext:") +print(" promote-apply.sh --target sandbox # rehearse the replay (safe)") +print(" promote-apply.sh --target prod # WRITES PROD — needs DOLIBARR_PROD_WRITE_KEY") +print(" # + ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD") +PY diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/thirdparty-create.sh b/.claude/skills/dolibarr-sandbox-write/scripts/thirdparty-create.sh index a1f374a..85d1b40 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/thirdparty-create.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/thirdparty-create.sh @@ -16,7 +16,7 @@ # thirdparty-create.sh fournisseur.json set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -W="${SCRIPT_DIR}/dol-write.sh" +W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" SRC="${1:-}" if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi diff --git a/bin/arcodange b/bin/arcodange index 98ff820..6ef7616 100755 --- a/bin/arcodange +++ b/bin/arcodange @@ -89,6 +89,10 @@ COMMANDS creditnote Create an avoir (credit note) of a source invoice write [body] Raw host-guarded write + promote Replay a reviewed change-set sandbox -> prod (ADR-0003) + plan Human-readable review of the change-set + apply [--target sandbox|prod] Replay it (prod is key+confirm gated) + whoami GET /users/info — confirm auth ping GET /status — liveness + Dolibarr version curl Raw read-only curl through dol-curl.sh @@ -306,6 +310,31 @@ EOF esac ;; + promote) + sub="${1:-help}"; shift || true + case "${sub}" in + plan) exec "${SKILLS}/dolibarr-sandbox-write/scripts/promote-plan.sh" "$@" ;; + apply) exec "${SKILLS}/dolibarr-sandbox-write/scripts/promote-apply.sh" "$@" ;; + help|-h|--help) + cat <<'EOF' +arcodange promote — replay a reviewed sandbox change-set onto a target. + +A manifest is a JSON array of write ops with symbolic refs (@name) instead of +ids, so the same file replays on sandbox or prod (an invoice refs @tp1, its +just-created thirdparty). See dolibarr-sandbox-write/examples/promote-manifest.json. + + plan Human-readable review of the change-set + apply [--target sandbox|prod] + Replay it (sandbox = rehearse; prod = real) + +PROD apply is gated: requires DOLIBARR_PROD_WRITE_KEY + ARCO_PROMOTE_CONFIRM= +I-UNDERSTAND-THIS-WRITES-PROD in the environment (the prod key is never stored). +EOF + ;; + *) echo "arcodange promote: unknown subcommand '${sub}' (try 'arcodange promote help')" >&2; exit 2 ;; + esac + ;; + whoami) exec "${DOLC}" /users/info ;; -- 2.49.1