Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/config-as-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@promocean/cli": minor
"@promocean/contracts": minor
---

New package **`@promocean/cli`** (`promocean` binary): `export`/`import`
commands for config-as-code — pull a project's placements, achievements,
timed events, offers, rewards, and project settings into a single JSON
file, and push edits back through a plan-before-apply workflow
(`--dry-run` prints the plan and exits 2 if it would change anything,
0 if not — a ready-made CI drift check; `--prune` additionally deletes
server-side content absent from the file). The config-plane secret is read
only from `PROMOCEAN_CONFIG_SECRET`, never a flag.

`@promocean/contracts` gains the schemas backing the config file and the
import request/response (`configFileSchema`, `importRequestSchema`,
`importResponseSchema`, and their inferred `ConfigFile`/`ImportRequest`/
`ImportResponse` types) — additive, no existing schema changed.
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ jobs:
# complexity for 3 small alpine images. Revisit if build time grows.
- run: docker compose --profile stack build
- run: docker compose --profile stack up -d --wait
# config-sync.spec.ts execFiles packages/cli/dist/cli.js and imports
# @promocean/contracts's dist — neither is produced by the docker build
# above (that builds the stack images, not the host workspace). Build the
# CLI here (turbo pulls in @promocean/contracts as a dependency).
- run: pnpm turbo run build --filter=@promocean/cli
- run: pnpm --filter demo exec playwright install --with-deps chromium
- run: pnpm --filter demo e2e
- if: failure()
Expand Down
12 changes: 7 additions & 5 deletions LICENSING.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Licensing

Embeddable client code (the SDK, widgets, shared schemas, and shared tooling
config) ships MIT so integrators can adopt it in their own codebases without a
copyleft obligation; everything that makes up the Promocean platform itself
(the CMS, the runtime API, the demo app, and the core/domain and adapter
packages behind them) stays GPL-3.0-only for the open-core angle.
Embeddable client code (the SDK, widgets, shared schemas, the config-as-code
CLI, and shared tooling config) ships MIT so integrators can adopt it in
their own codebases without a copyleft obligation; everything that makes up
the Promocean platform itself (the CMS, the runtime API, the demo app, and
the core/domain and adapter packages behind them) stays GPL-3.0-only for the
open-core angle.

| Package | License |
| ---------------------------- | ------------ |
Expand All @@ -18,6 +19,7 @@ packages behind them) stays GPL-3.0-only for the open-core angle.
| `packages/contracts` | MIT |
| `packages/sdk` | MIT |
| `packages/widgets` | MIT |
| `packages/cli` | MIT |
| `packages/config` | MIT |

Each MIT package carries its own `LICENSE` file; the root `LICENSE` covers
Expand Down
78 changes: 75 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,15 @@ coupons; `campaign-lifecycle.spec.ts` proves the seeded recurring `Weekly
Happy Hour` event reports a consistent `recurrence`/`nextOccurrenceStartsAt`
on the live feed and renders in the countdown widget, and that retroactive
achievement backfill is idempotent after a live unlock (both via a direct
API call and the `/stats` page's operator-facing backfill form). With cms +
api already running (per above):
API call and the `/stats` page's operator-facing backfill form);
`config-sync.spec.ts` drives `@promocean/cli` as a real subprocess against
the running stack — export the seeded project, a scripted edit (raise
`first_lesson`'s `pointsValue`, add a new achievement), a `--dry-run` plan
check (exit code 2, exactly the expected creates/updates), an apply (exit
0), polling `/v1/users/:userId/achievements` and `/v1/users/:userId/wallet`
until the change is visible through the api's config-plane cache (see
"Config as code" above), and a final re-import proving the file and server
now agree (`--dry-run` exits 0). With cms + api already running (per above):

pnpm --filter demo exec playwright install chromium
pnpm --filter demo e2e
Expand Down Expand Up @@ -408,6 +415,71 @@ code via validate/redeem). This is deliberate: the catalog is safe to expose
to a publishable key/browser context without leaking a shared promo code to
anyone who hasn't earned it.

## Config as code

`@promocean/cli` (`npm i -g @promocean/cli`, MIT) exports a project's whole
configuration — project settings (`pointRules`, `registeredEventTypes`,
`allowedOrigins`), placements, achievements, timed events, offers, and
rewards — to a single JSON file, and imports one back with a
plan-before-apply workflow. This is the operator-facing alternative to
hand-editing content in the Strapi admin: put the file under version
control, review changes as a diff, and apply them the same way in every
environment.

**Authoring loop:**

promocean export --url https://cms.example.com --project <projectId> --out config.json
# edit config.json by hand (or with tooling) ...
promocean import --url https://cms.example.com --project <projectId> --file config.json --dry-run
# inspect the printed plan, then apply for real:
promocean import --url https://cms.example.com --project <projectId> --file config.json

Both commands read the config-plane secret from the `PROMOCEAN_CONFIG_SECRET`
environment variable only (never a flag), matching the `x-config-secret`
header the config-plane endpoints require — the same operator-only trust
model as every other config-plane read. Content is matched between the file
and the server **by slug** (achievements, timed events, offers, rewards,
and placements each carry a required, project-unique `slug`), not by
internal id — a file authored against one project imports cleanly into any
other project with the same slugs, which is what makes the format portable
across environments/instances (see the runtime-history caveat below for the
one place this portability doesn't fully extend).

**CI drift check via exit code 2:** `import --dry-run` exits `0` when the
computed plan has no creates/updates/deletes anywhere (the file already
matches the server), `2` when it would change something, and `1` on any
error (bad file, HTTP failure, or a partially-applied 422). A CI job that
runs `promocean import --dry-run` against your checked-in config file and
fails the build on a non-zero exit catches config drift — someone edited
content directly in the CMS admin instead of through the file — before it
silently diverges further; exit `2` specifically means "the file and the
server disagree," which is exactly the drift signal such a job wants to
gate on (as opposed to exit `1`, which means the check itself couldn't run).

**Prune semantics:** by default, import only creates and updates — content
that exists on the server but is absent from the file is left alone. Pass
`--prune` to additionally delete server-side content (per content type) that
the file doesn't mention. Without `--prune`, deleting a row from your config
file is a no-op on the next import; with it, deleting a row from the file
deletes that row on the server. When `--prune` is used, an import is rejected
upfront (HTTP 400, before any write) if a kept offer references a placement or
timed event that the file omits — because that target would be deleted by the
prune, orphaning the offer.

**Runtime-history caveat:** import matches existing content by slug and
updates it *in place* when the file's fields differ — this preserves the
underlying documentId, so anything keyed off it (analytics rows, wallet
ledger `sourceRef`s, delivered-webhook state, achievement unlock history)
stays attached to the same logical achievement/offer/reward/timed-event
across edits. Deleting a row from the file (with `--prune`) and re-adding it
later under the same slug is a **delete + recreate**, not an update — the
new row gets a brand-new documentId, so continuity with anything that
referenced the old one is **not** promised. Prefer editing a row in place
over delete-then-recreate whenever preserving that history matters.

See `packages/cli/README.md` for the full command reference (flags, exit
codes, 422 rendering, programmatic use).

## Webhooks

The api dispatches signed `POST` webhooks for `timed_event.live` /
Expand Down Expand Up @@ -467,7 +539,7 @@ Scheduler tuning (all optional, read once at process start):

## Publishing

MIT packages (`@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`) publish via a two-step manual flow:
MIT packages (`@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`, `@promocean/cli`) publish via a two-step manual flow:

1. **Describe the change**: Run `pnpm changeset` to create a `.changeset/*.md` file (describes the change type and affected packages). Commit this file with your PR.

Expand Down
29 changes: 22 additions & 7 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Releasing

The publishable packages are the three MIT-licensed ones (see `LICENSING.md`):
The publishable packages are the four MIT-licensed ones (see `LICENSING.md`):

| Package | Publish? |
|---|---|
| `@promocean/contracts` | yes (npm) |
| `@promocean/sdk` | yes (npm) |
| `@promocean/widgets` | yes (npm) |
| `@promocean/cli` | yes (npm) — ships a `promocean` binary, not a library; same publish path |
| `@promocean/core`, `@promocean/adapter-db`, `@promocean/adapter-strapi`, `@promocean/config`, `api`, `cms`, `demo` | no — `"private": true` in their `package.json` |

Versioning is driven by [Changesets](https://github.com/changesets/changesets).
Expand Down Expand Up @@ -48,24 +49,30 @@ pnpm publish -r --dry-run --no-git-checks
```

For each of `@promocean/contracts`, `@promocean/sdk`, `@promocean/widgets`,
check the printed tarball contents:
`@promocean/cli`, check the printed tarball contents:

- [ ] `dist/` present; `src/` and `test/` **absent** (the `files` allowlist is
`["dist", "README.md", "LICENSE"]`)
`["dist", "README.md", "LICENSE"]` — `@promocean/cli`'s is the same set,
listed as `["dist", "LICENSE", "README.md"]`)
- [ ] `LICENSE` (MIT) and `README.md` present
- [ ] version matches the `changeset version` bump
- [ ] for `@promocean/cli` only: `dist/cli.js` keeps its `#!/usr/bin/env node`
shebang and `bin.promocean` in the packed manifest points at it

Then inspect the packed manifests directly (dry-run output doesn't show them):

```sh
pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets exec \
pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets --filter @promocean/cli exec \
pnpm pack --pack-destination /tmp/promocean-pack
for f in /tmp/promocean-pack/*.tgz; do tar -xOzf "$f" package/package.json; done
```

- [ ] every `workspace:*` dep is rewritten to a real version
(e.g. `"@promocean/contracts": "0.1.0"`)
- [ ] `main`/`types` point into `dist/`
- [ ] `main`/`types` point into `dist/` — **except `@promocean/cli`**, which is a
bin-only package (no library entry point) and by design declares neither;
for it, verify `bin.promocean` points into `dist/` instead (see the shebang
check above)

Note: `@promocean/core`, `@promocean/adapter-db`, `@promocean/adapter-strapi`,
`@promocean/config`, and `api` are all `"private": true`, so `pnpm publish -r`
Expand Down Expand Up @@ -103,7 +110,7 @@ TOKEN=$(curl -fsS -XPUT http://localhost:4873/-/user/org.couchdb.user:rehearsal
npm config set //localhost:4873/:_authToken "$TOKEN"
npm whoami --registry http://localhost:4873 # -> rehearsal

pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets \
pnpm --filter @promocean/contracts --filter @promocean/sdk --filter @promocean/widgets --filter @promocean/cli \
publish --registry http://localhost:4873 --no-git-checks
```

Expand All @@ -123,9 +130,17 @@ npm init -y
npm install @promocean/contracts @promocean/sdk @promocean/widgets react react-dom \
--registry http://localhost:4873
node smoke.mjs # see below

# @promocean/cli ships a binary, not a library — its own smoke check is
# installing it globally and confirming the bin resolves and runs:
npm install -g @promocean/cli --registry http://localhost:4873
promocean export --url http://localhost:1 --project x 2>&1 | grep -q PROMOCEAN_CONFIG_SECRET \
&& echo 'cli bin OK (env-guard message printed)'
npm uninstall -g @promocean/cli
```

`smoke.mjs` must exercise all three packages:
`smoke.mjs` must exercise all three library packages (`@promocean/cli` is
smoke-tested separately above, as a binary rather than an import):

- parse one `@promocean/contracts` schema (e.g. `rewardSchema.parse({...})`)
- `new Promocean({ publishableKey, baseUrl, fetchImpl: mockFetch })` and
Expand Down
6 changes: 6 additions & 0 deletions apps/cms/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ COPY --from=prod-deps --chown=node:node /app .
# - public/ static assets + local upload provider target
# - database/ app-level migrations dir (appDir/database/migrations)
# - favicon.png strapi::favicon middleware resolves it at appDir root
# - packages/contracts/dist the config-plane controller's runtime `require('@promocean/contracts')`
# (Sprint 11) — prod-deps only installs from out/json (package.json skeletons, no
# built output), so without this copy the workspace symlink resolves to a
# dist-less package and Strapi fails to boot (matches apps/api/Dockerfile's
# existing pattern for its own workspace deps).
COPY --from=installer --chown=node:node /app/apps/cms/dist ./apps/cms/dist
COPY --from=installer --chown=node:node /app/apps/cms/public ./apps/cms/public
COPY --from=installer --chown=node:node /app/apps/cms/database ./apps/cms/database
COPY --from=installer --chown=node:node /app/apps/cms/tsconfig.json /app/apps/cms/favicon.png ./apps/cms/
COPY --from=installer --chown=node:node /app/packages/contracts/dist ./packages/contracts/dist
USER node
EXPOSE 1337
# Strapi exposes /_health returning 204 with no auth — perfect for a probe.
Expand Down
4 changes: 3 additions & 1 deletion apps/cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
"typecheck": "tsc --noEmit",
"test": "echo ok",
"verify:lifecycles": "tsx scripts/verify-lifecycles.ts",
"verify:config-sync": "tsx scripts/verify-config-sync.ts",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@promocean/contracts": "workspace:*",
"@strapi/plugin-cloud": "5.50.0",
"@strapi/plugin-users-permissions": "5.50.0",
"@strapi/strapi": "5.50.0",
Expand All @@ -38,7 +40,7 @@
"typescript": "^5"
},
"engines": {
"node": ">=20.0.0 <=26.x.x",
"node": ">=20.19.0 <=26.x.x",
"npm": ">=6.0.0"
},
"strapi": {
Expand Down
Loading
Loading