Merge pull request 'feat(skills,cli): promote-to-prod replay (ADR-0003 capstone) + supplier payment fix' (#23) from claude/dolibarr-promote into main
This commit was merged in pull request #23.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" } }
|
||||
]
|
||||
@@ -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
|
||||
|
||||
39
.claude/skills/dolibarr-sandbox-write/scripts/dol-prod-write.sh
Executable file
39
.claude/skills/dolibarr-sandbox-write/scripts/dol-prod-write.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
68
.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh
Executable file
68
.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh
Executable 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
|
||||
40
.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh
Executable file
40
.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -89,6 +89,10 @@ COMMANDS
|
||||
creditnote Create an avoir (credit note) of a source invoice
|
||||
write <METHOD> <path> [body] Raw host-guarded write
|
||||
|
||||
promote Replay a reviewed change-set sandbox -> prod (ADR-0003)
|
||||
plan <manifest.json> Human-readable review of the change-set
|
||||
apply <manifest.json> [--target sandbox|prod] Replay it (prod is key+confirm gated)
|
||||
|
||||
whoami GET /users/info — confirm auth
|
||||
ping GET /status — liveness + Dolibarr version
|
||||
curl <path> 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 <manifest.json> Human-readable review of the change-set
|
||||
apply <manifest.json> [--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
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user