feat(skills,cli): promote-to-prod replay (ADR-0003 capstone) + supplier payment fix

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 <manifest> --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) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 23:48:47 +02:00
parent e4c67c0108
commit 00d86b47a3
10 changed files with 229 additions and 9 deletions

View File

@@ -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="<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.

View File

@@ -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" } }
]

View File

@@ -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

View File

@@ -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 <METHOD> <path> [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

View File

@@ -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

View File

@@ -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:

View File

@@ -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 <manifest.json> [--target sandbox|prod]
#
# --target prod requires, in the environment (never stored):
# DOLIBARR_PROD_WRITE_KEY=<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 <manifest.json> [--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

View File

@@ -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 <manifest.json>}"
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 <manifest> --target sandbox # rehearse the replay (safe)")
print(" promote-apply.sh <manifest> --target prod # WRITES PROD — needs DOLIBARR_PROD_WRITE_KEY")
print(" # + ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD")
PY

View File

@@ -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