@oxy-hq/cli 0.1.0

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.
Files changed (39) hide show
  1. package/README.md +163 -0
  2. package/dist/main.mjs +6688 -0
  3. package/dist/main.mjs.map +1 -0
  4. package/dist/mcp-VdontkUr.mjs +316 -0
  5. package/dist/mcp-VdontkUr.mjs.map +1 -0
  6. package/json-schemas/agent-test.json +104 -0
  7. package/json-schemas/agentic.json +406 -0
  8. package/json-schemas/app.json +1884 -0
  9. package/json-schemas/config.json +2215 -0
  10. package/json-schemas/flow-test.json +124 -0
  11. package/json-schemas/global-semantics.json +782 -0
  12. package/json-schemas/workflow.json +1907 -0
  13. package/package.json +65 -0
  14. package/skills/oxy-airway-pipelines/SKILL.md +338 -0
  15. package/skills/oxy-automations/SKILL.md +67 -0
  16. package/skills/oxy-cli/SKILL.md +134 -0
  17. package/skills/oxy-custom-apps/SKILL.md +330 -0
  18. package/skills/oxy-data-architecture/SKILL.md +225 -0
  19. package/skills/oxy-semantic-modeling/SKILL.md +216 -0
  20. package/template/.gitattributes +2 -0
  21. package/template/.github/scripts/ci-publish.sh +77 -0
  22. package/template/.github/scripts/discover-apps.sh +175 -0
  23. package/template/.github/scripts/publish-env.sh +55 -0
  24. package/template/.github/workflows/publish.yaml +653 -0
  25. package/template/.github/workflows/validate.yaml +264 -0
  26. package/template/.nvmrc +1 -0
  27. package/template/.oxyc-managed +297 -0
  28. package/template/README.md +414 -0
  29. package/template/_gitignore +32 -0
  30. package/template/apps/.gitkeep +0 -0
  31. package/template/config.yml +53 -0
  32. package/template/memory/.gitkeep +0 -0
  33. package/template/package.json +17 -0
  34. package/template/pipelines/.gitkeep +0 -0
  35. package/template/pnpm-workspace.yaml +32 -0
  36. package/template/scripts/dev.sh +443 -0
  37. package/template/semantics/topics/.gitkeep +0 -0
  38. package/template/semantics/views/.gitkeep +0 -0
  39. package/template/workflows/.gitkeep +0 -0
@@ -0,0 +1,216 @@
1
+ ---
2
+ name: oxy-semantic-modeling
3
+ description: Use when writing, reviewing, or debugging an Oxy semantic model — `.view.yml` views, `.topic.yml` topics, measures, dimensions, entities, and the `meta:` freshness block — in a customer workspace, and when one misbehaves: a measure that vanishes after switching topics, "measure not found", a topic returning no rows or missing joins, or an agent reporting a partial day as a complete number. Carries the craft and the traps the reference docs do not.
4
+ ---
5
+
6
+ # Oxy semantic modeling
7
+
8
+ **The reference is upstream and maintained — read it rather than guessing:**
9
+ <https://www.oxygen-hq.com/docs/guide/build/semantic-model/simple-model>
10
+ (the section entry point; `views`, `entities`, `dimensions`, `measures`,
11
+ `topics`, `modeling`, and `use-in-agents` sit beside it).
12
+
13
+ That page owns the field lists. This skill deliberately does **not** restate
14
+ them — a copied schema stays confidently wrong long after the real one moves.
15
+ What follows is the craft and the traps it does not carry, with every example
16
+ taken from Poke House's real 39 views and 23 topics.
17
+
18
+ ## View anatomy
19
+
20
+ A view is one physical table plus the vocabulary an agent is allowed to use
21
+ against it.
22
+
23
+ ```yaml
24
+ name: sales_daily
25
+ datasource: "clickhouse"
26
+ table: "restaurant_analytics.restaurant_analytics___sales_daily_metrics"
27
+ ```
28
+
29
+ **`entities` are the join graph, not decoration.** The `primary` entity names
30
+ the grain of the row; `foreign` entities are the edges to other views; `parent:`
31
+ states hierarchy (a sales day belongs to a restaurant).
32
+
33
+ ```yaml
34
+ entities:
35
+ - name: sales_day
36
+ type: primary
37
+ key: sales_day_key
38
+ parent: restaurant
39
+ - name: restaurant
40
+ type: foreign
41
+ key: restaurant_id
42
+ ```
43
+
44
+ **`key` names a dimension, not a column.** In `restaurants.view.yml` the entity
45
+ `restaurant` has `key: restaurant_id`, and the `restaurant_id` *dimension* is
46
+ `expr: guid` — the physical column is `guid`. So resolve a `key` against the
47
+ view's `dimensions:` list, never against the table's columns; the reference's
48
+ `entities` page carries the same warning.
49
+
50
+ **`dimensions`** are what you slice by, **`measures`** what you aggregate. Both
51
+ take `synonyms`; measures also take `filters` and `drivers`. House style keeps
52
+ entity keys single — even a compound natural key is one pre-concatenated
53
+ dimension (`sales_day_key`); no view uses the documented composite `keys:`.
54
+
55
+ ## The description is a contract with the agent, not a label
56
+
57
+ `sales_daily`'s description, verbatim:
58
+
59
+ > Daily sales metrics aggregated by restaurant and business date. Loaded several
60
+ > times daily; the current business day is always intraday-partial - the prior
61
+ > full day is the latest complete day.
62
+
63
+ The second sentence is the whole point. Without it an agent asked "how are sales
64
+ today?" reports a half-loaded partial day as a real number, confidently and with
65
+ no warning — wrong in a way nobody can see downstream. Write descriptions that
66
+ say when the data is complete and what it excludes, not what the table holds.
67
+
68
+ ## The `meta:` freshness block
69
+
70
+ Four keys — `freshness_watermark_column`, `freshness_expected_cadence`,
71
+ `freshness_complete_through`, `freshness_caveat` — that state a view's own lag
72
+ characteristics so the agent can qualify an answer instead of guessing.
73
+
74
+ **Only 11 of 39 views carry one.** That low number is the point: it is the most
75
+ under-used high-value feature in a real model, and adding one is the cheapest
76
+ quality win available on any view fed by a batch pipeline.
77
+
78
+ - **It is undocumented.** Not one of the four keys appears anywhere in the docs,
79
+ and `views.mdx`'s own property table does not list `meta`. It is a working
80
+ project convention, not a schema feature you can look up. **Copy the block from
81
+ `sales_daily.view.yml`** — that is the canonical exemplar.
82
+ - **Every value is wrapped as a single-element list** (`["prior full day
83
+ (America/Los_Angeles)"]`) even though each reads as a scalar. "Simplifying" it
84
+ to a bare string silently deviates from all 11 existing blocks.
85
+
86
+ ## Craft
87
+
88
+ **`synonyms` are the natural-language surface.** Map both domain jargon and plain
89
+ English onto one name; colliding common words across views is normal and wanted.
90
+
91
+ ```yaml
92
+ - name: total_net_sales
93
+ synonyms: ["net sales", "net revenue", "revenue", "sales"]
94
+ - name: sales_per_guest
95
+ synonyms: ["per guest average", "guest average", "PPA"]
96
+ ```
97
+
98
+ **`samples` on enum-ish dimensions** tell the agent the valid values, so it
99
+ filters on `"PAID"` instead of inventing `"paid"`. Give the *full* set, not a
100
+ sample — `["OPEN", "PAID", "CLOSED"]`, `["negative", "neutral", "positive"]`.
101
+
102
+ **Derived time dimensions so the agent never writes date arithmetic.** Ship
103
+ `business_year`, `business_month`, `business_quarter`, `day_of_week`,
104
+ `is_weekend` as dimensions of their own, each re-derived from the raw column
105
+ rather than referencing the base date dimension:
106
+
107
+ ```yaml
108
+ - name: business_quarter
109
+ expr: "toQuarter(toDate(parseDateTimeBestEffort(toString(business_date))))"
110
+ ```
111
+
112
+ **`drivers` must state a mechanism, not a vibe.** The quality bar, from
113
+ `sales_daily`:
114
+
115
+ ```yaml
116
+ - name: total_net_sales
117
+ drivers:
118
+ - measure: sales_daily.discount_rate
119
+ direction: negative
120
+ strength: strong
121
+ confidence: high
122
+ description: "A larger share of gross sales given away as discounts lowers net sales. Unlike discount dollars, the rate does not move with volume."
123
+ ```
124
+
125
+ Note what the `description` does: it names the causal path *and* says why the
126
+ obvious alternative (discount dollars) was rejected. The weak examples in the
127
+ corpus — in `quickbooks_pl.view.yml` — carry `direction`, `strength`,
128
+ `confidence`, and a `lag`, but no `description` at all, so the mechanism is never
129
+ stated and a reader cannot tell whether the edge was reasoned or guessed. A
130
+ driver without a `description` is not finished. Only 8 of 39 views use
131
+ `drivers`, and the field is absent from the semantic-model docs — see the traps.
132
+
133
+ ## Topics are question areas, not tables
134
+
135
+ A topic is a `base_view` plus the views it may join to, and the filters that
136
+ should always apply. That is the whole shape — all 23 real topics use only
137
+ `name`, `description`, `views`, `base_view`, `default_filters`.
138
+
139
+ ```yaml
140
+ name: orders
141
+ base_view: orders
142
+ views:
143
+ - orders
144
+ - order_checks
145
+ - order_selections
146
+ - restaurants
147
+ - dining_options
148
+ default_filters:
149
+ - field: "is_voided"
150
+ eq:
151
+ value: false
152
+ ```
153
+
154
+ Name topics after the questions people ask, and let `default_filters` carry the
155
+ "obviously we don't count voided orders" rules so no one has to remember them.
156
+
157
+ ## Traps
158
+
159
+ **The `airhouse_*` twins are a migration artifact, and they are not mirrors.**
160
+ The ClickHouse → Airhouse migration is in flight, so **11 of the 39 views exist
161
+ twice** (22 views, 11 twin pairs) — plus 3 further `airhouse_*` views with no
162
+ ClickHouse counterpart at all, where discount- and payment-level detail lives
163
+ only on the new side. Three consequences, in order of how much time they cost:
164
+
165
+ 1. **The twins have diverged.** `sales_daily` defines a `discount_rate` measure;
166
+ `airhouse_sales_daily` does not. Switch topics mid-conversation from one
167
+ family to the other and a query that worked seconds ago fails with "measure
168
+ not found", with nothing anywhere saying the two views were ever supposed to
169
+ match. Before assuming a measure exists on a twin, read the twin.
170
+ 2. **The two families cannot join to each other, and fail silently when you
171
+ try.** Every `airhouse_*` view prefixes its **foreign** entity names —
172
+ the edges — as `airhouse_restaurant`, `airhouse_order`, `airhouse_check`,
173
+ while its twin uses bare `restaurant`, `order`, `check`. Joins resolve by
174
+ matching entity *names*, so a topic mixing families does not error — it just
175
+ fails to auto-join. You get missing rows, not a config failure. No real topic
176
+ mixes them; keep it that way.
177
+
178
+ **The prefix is not universal, and the exception runs the other way.** The
179
+ three twin-less views from above leave their **primary** entity bare:
180
+ `airhouse_order_discounts` → `discount_application`,
181
+ `airhouse_order_payments` → `payment`, `airhouse_selection_discounts` →
182
+ `selection_discount_application`. Their foreign entities are still prefixed,
183
+ so rule 2 holds — but the latent failure inverts. A bare primary is exactly
184
+ what a ClickHouse-family view would declare, so if anyone ever adds a
185
+ `payment` or `discount_application` entity on the old side, those two views
186
+ start auto-joining *across* families rather than failing to join: wrong rows
187
+ silently, instead of missing rows silently. No such collision exists in the
188
+ corpus today (grep: each of the three names is declared in exactly one
189
+ view). Prefix the primary too on any new `airhouse_*` view.
190
+ 3. **Do not copy the pattern.** Duplicating a view to change backends is a
191
+ migration tactic with an end date, not a modelling technique.
192
+
193
+ **Repeated defensive casting is a smell.** `toDate(parseDateTimeBestEffort(
194
+ toString(business_date)))` appears in **6 derived dimensions of `sales_daily`** —
195
+ the same three-deep cast chain re-typed six times because the underlying column
196
+ is not a date. Fix the column type once, upstream in the pipeline, rather than in
197
+ every `expr`. (Counting these by grep over-reports: a seventh occurrence is
198
+ inside `measures:`, not `dimensions:`. The dimension count is 6.)
199
+
200
+ **`drivers` and `parent:` are documented, but not where you would look.** Neither
201
+ appears in the semantic-model pages — `drivers` is explained under
202
+ <https://www.oxygen-hq.com/docs/guide/build/world-model/metric-tree> and
203
+ `parent:` under <https://www.oxygen-hq.com/docs/guide/build/world-model>. Worse,
204
+ the metric-tree page links back to `measures` claiming it defines the measures
205
+ "and their drivers", which it does not. Read only the semantic-model section and
206
+ you will conclude both fields do not exist and model without them.
207
+
208
+ **View-qualified `{{ }}` references are the one-off, not the norm.** Across all
209
+ 39 views the `{{ view.measure }}` form appears in exactly **one expression** —
210
+ the `discount_rate` measure at `sales_daily.view.yml:129`, which uses it twice
211
+ in that one line (`{{ sales_daily.total_discounts }} / NULLIF({{
212
+ sales_daily.total_gross_sales }}, 0)`). So a grep returns 2 hits from 1 site;
213
+ neither number is "the norm". Every other `{{ }}` in the corpus is a bare,
214
+ unqualified dimension name inside a measure's `filters:` — `{{is_voided}}`. For
215
+ an ordinary same-view filter, write the bare form; copying the qualified one
216
+ means copying the single outlier in the codebase.
@@ -0,0 +1,2 @@
1
+ # Generated index: never hand-merge. Regenerate with oxyc instead.
2
+ memory/MEMORY.md merge=union linguist-generated=true
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Does this repo publish its custom apps from CI, or does a human do it?
3
+ #
4
+ # OFF by default, and the default is the whole point. `customer-apps` — the
5
+ # monorepo Oxy ships its own custom apps from — deleted its publish CI outright
6
+ # and its engineers publish from their machines with `oxy login` and
7
+ # `oxy publish`; its own docs say "there is no GitHub Actions / CI". A repo
8
+ # scaffolded from this template therefore behaves the same way unless somebody
9
+ # asks for CI publishing, rather than arriving demanding a long-lived token the
10
+ # rest of the org has already decided it does not want.
11
+ #
12
+ # In its own file for the same reason publish-env.sh is: a `run:` block cannot
13
+ # be executed anywhere but a runner, so anything left inline is unexecuted code
14
+ # until the first real push. This is the switch the whole opt-in rests on, so it
15
+ # is the last thing that should be unexecuted.
16
+ #
17
+ # THE SWITCH IS EXPLICIT, AND IT IS NOT THE TOKEN. Skipping the publish whenever
18
+ # no OXY_TOKEN reached the job would have been one line, and it would have
19
+ # destroyed the property publish.yaml is built around: that a publish into a
20
+ # misconfigured environment is a loud credential failure rather than a quiet
21
+ # success. Inferred from the token, "we do not publish from CI" and "somebody
22
+ # forgot production's OXY_TOKEN" are the same observation — and the second one
23
+ # then ships nothing, silently, for as long as nobody looks. Two causes need two
24
+ # states, which is what this file is.
25
+ #
26
+ # A VARIABLE rather than a secret, for two reasons that both matter. It is read
27
+ # in the `build` job — the only job that runs when publishing is off, and so the
28
+ # only one that can say anything about it — and that job must reference no
29
+ # `secrets.` at all: it executes the bundles' own postinstall scripts, and the
30
+ # credential boundary in publish.yaml is enforced by a test that fails on any
31
+ # secret reference there. The second reason is plainer: a switch whose entire
32
+ # job is to tell "off on purpose" apart from "misconfigured" has to be legible,
33
+ # in Settings and in a run log. A secret is neither.
34
+ #
35
+ # Contract:
36
+ # $1 the raw value, defaulting to $OXY_CI_PUBLISH — which publish.yaml
37
+ # fills from `vars.OXY_CI_PUBLISH`. Passed explicitly only by the
38
+ # tests.
39
+ # stdout `on` or `off`, one line. Nothing else, ever: the workflow captures
40
+ # stdout into a job output that gates the publish job.
41
+ # stderr when off, the ::notice:: that names the self-serve command and
42
+ # says how to turn CI publishing on.
43
+ # exit 0 for a value this file recognises, an absent one included.
44
+ # exit 1 for a value it does not.
45
+ #
46
+ # That last refusal is deliberate. `OXY_CI_PUBLISH=ture` cannot be read as
47
+ # either answer, and both guesses are wrong in a way nobody would see: read as
48
+ # off, the repo quietly stops publishing; read as on, it publishes when nobody
49
+ # asked. It fails in the `build` job, which holds no credential, before a single
50
+ # dependency is installed.
51
+
52
+ set -euo pipefail
53
+
54
+ raw="${1:-${OXY_CI_PUBLISH:-}}"
55
+
56
+ # Case-folded through `tr` because bash 3.2 — which is what macOS ships and what
57
+ # this repo's suite runs on — has no ${var,,}.
58
+ value="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
59
+
60
+ case "$value" in
61
+ # Absent is the default and the default is off. The explicit negatives are
62
+ # listed beside it for the person who writes `false` meaning it: a rule of
63
+ # "any non-empty value is on" would read that as a publish, which is the one
64
+ # misreading of a switch that costs something.
65
+ ''|false|no|off|0)
66
+ printf 'off\n'
67
+ printf '::notice::CI publishing is off in this repo, which is the default. This run built the app bundles and published nothing. Publishing here is self-serve: from an app directory under apps/, run "oxy login --env dev" once and then "oxy publish --env dev" (or --env production). To publish from CI instead, add a repository VARIABLE named OXY_CI_PUBLISH with the value true — Settings, then Secrets and variables, then Actions, then the Variables tab — and give each GitHub environment its own OXY_TOKEN.\n' >&2
68
+ ;;
69
+ true|yes|on|1)
70
+ printf 'on\n'
71
+ ;;
72
+ *)
73
+ printf '::error::OXY_CI_PUBLISH is set to "%s", which is neither on (true, yes, on, 1) nor off (false, no, off, 0, or the variable removed). Refusing to guess, because both guesses are silent: read as off this repo would stop publishing with nobody told, and read as on it would publish when nobody asked. Fix the value or delete the variable.\n' \
74
+ "$raw" >&2
75
+ exit 1
76
+ ;;
77
+ esac
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bash
2
+ # Which custom apps does this repo hold, and where does each one's build land?
3
+ #
4
+ # Split out of publish.yaml on purpose. GitHub Actions cannot be executed
5
+ # locally, so anything left inline in a `run:` block is unexecuted code until
6
+ # the first real push. This file is the part that CAN be run here, and it is
7
+ # run — the same bytes, not a transcription of them.
8
+ #
9
+ # Contract:
10
+ # stdout one line per app, "<app-dir><TAB><out-dir>", relative to the repo
11
+ # root, sorted. Empty when the repo has no apps.
12
+ # exit 0 including for zero apps. A freshly scaffolded repo has none, and
13
+ # its CI must be green on day one — a template that ships a red
14
+ # check teaches the team to stop reading checks.
15
+ # exit 1 only for a repo that HAS apps and cannot publish them: a manifest
16
+ # that will not parse, an app with no resolvable identity, or a
17
+ # missing lockfile.
18
+ # $GITHUB_OUTPUT gains `count=<n>` when that variable is set, so the
19
+ # workflow's skip condition and this script's answer are one value
20
+ # rather than two that can disagree.
21
+
22
+ set -euo pipefail
23
+
24
+ root="${1:-.}"
25
+
26
+ emit_count() {
27
+ if [ -n "${GITHUB_OUTPUT:-}" ]; then
28
+ printf 'count=%s\n' "$1" >> "$GITHUB_OUTPUT"
29
+ fi
30
+ }
31
+
32
+ # `find` prints nothing and exits 0 when nothing matches, which is exactly
33
+ # what a day-one repo produces. That is why discovery is a `find`, not a glob:
34
+ # `for d in apps/*/` under `set -e` yields the literal string `apps/*/`, and a
35
+ # legitimately-empty match then reads as either a phantom app or a hard
36
+ # failure depending on what runs next. This project has been bitten by that
37
+ # twice.
38
+ #
39
+ # -maxdepth 3 covers both layouts pnpm-workspace.yaml declares:
40
+ # apps/<app>/oxy-app.json (depth 2)
41
+ # apps/<org>/<app>/oxy-app.json (depth 3)
42
+ # The prunes keep a dependency's own manifest, or a copy left inside a
43
+ # previous build output, from being published as if it were one of ours.
44
+ manifests=""
45
+ unpruned=""
46
+ if [ -d "$root/apps" ]; then
47
+ manifests="$(cd "$root" && find apps -maxdepth 3 \
48
+ \( -name node_modules -o -name out -o -name dist -o -name build \
49
+ -o -name .git -o -name .turbo \) -prune \
50
+ -o -type f -name oxy-app.json -print | LC_ALL=C sort)"
51
+ unpruned="$(cd "$root" && find apps -maxdepth 3 \
52
+ -type f -name oxy-app.json -print | LC_ALL=C sort)"
53
+ fi
54
+
55
+ # The prune above is right, but it is also SILENT, and silence is the wrong
56
+ # half of it: an app whose directory is legitimately named `dist`, `out` or
57
+ # `build` is dropped and the workflow then succeeds having published nothing.
58
+ #
59
+ # Warn about exactly that case and no other. A manifest under a pruned
60
+ # directory that sits INSIDE a directory which is itself an app —
61
+ # `apps/dashboard/out/oxy-app.json`, `apps/dashboard/node_modules/…` — is a
62
+ # build artifact or a dependency's own manifest, correctly ignored, and
63
+ # warning about it would be the noise that gets warnings ignored.
64
+ if [ -n "$unpruned" ]; then
65
+ while IFS= read -r candidate; do
66
+ [ -n "$candidate" ] || continue
67
+ if printf '%s\n' "$manifests" | grep -qxF "$candidate"; then
68
+ continue
69
+ fi
70
+ skipped_dir="${candidate%/oxy-app.json}"
71
+ parent="${skipped_dir%/*}"
72
+ # Nested inside a real app: intended, stays quiet.
73
+ if printf '%s\n' "$manifests" | grep -qxF "$parent/oxy-app.json"; then
74
+ continue
75
+ fi
76
+ printf '::warning file=%s::%s was skipped: a directory named "%s" is treated as build output, not an app. Rename it if this really is an app — nothing here will be published.\n' \
77
+ "$candidate" "$skipped_dir" "${skipped_dir##*/}" >&2
78
+ done <<EOF
79
+ $unpruned
80
+ EOF
81
+ fi
82
+
83
+ if [ -z "$manifests" ]; then
84
+ printf 'publish: no custom apps under apps/ — nothing to build, nothing to publish.\n' >&2
85
+ emit_count 0
86
+ exit 0
87
+ fi
88
+
89
+ if ! command -v jq >/dev/null 2>&1; then
90
+ printf '::error::jq is not on this runner, so oxy-app.json cannot be read. GitHub'"'"'s ubuntu-latest image ships it; a self-hosted runner may not.\n' >&2
91
+ exit 1
92
+ fi
93
+
94
+ buffer="$(mktemp)"
95
+ trap 'rm -f "$buffer"' EXIT
96
+
97
+ bad=0
98
+ count=0
99
+
100
+ while IFS= read -r manifest; do
101
+ app_dir="${manifest%/oxy-app.json}"
102
+
103
+ if ! jq -e 'type == "object"' "$root/$manifest" >/dev/null 2>&1; then
104
+ printf '::error file=%s::%s is not a JSON object. oxy publish reads this file for the app identity and will not get past it.\n' \
105
+ "$manifest" "$manifest" >&2
106
+ bad=1
107
+ continue
108
+ fi
109
+
110
+ # `oxy publish` resolves the app slug as `--app`, then OXY_APP, then the
111
+ # manifest, then the `<app>` segment of an `apps/<org>/<app>/` path — that
112
+ # is the IMPLEMENTATION's order (publish.rs:779-785). Note that
113
+ # `oxy publish --help` states a different one, manifest before OXY_APP; the
114
+ # code is what runs, and its own comment agrees with the code. It makes no
115
+ # difference here either way: this workflow sets neither the flag nor the
116
+ # env var, and both readings end `manifest, then path` — the only two links
117
+ # this check depends on.
118
+ slug="$(jq -r '.slug // ""' "$root/$manifest")"
119
+ if [ -z "$slug" ] || [ "$slug" = "null" ]; then
120
+ printf '::error file=%s::%s has no "slug". That is the app'"'"'s identity on the platform and there is nowhere else to get it.\n' \
121
+ "$manifest" "$manifest" >&2
122
+ bad=1
123
+ continue
124
+ fi
125
+
126
+ # Same resolution chain for the org (publish.rs:773-778), with one
127
+ # difference that matters: the path fallback only fires for the nested
128
+ # `apps/<org>/<app>/` layout. In the flat `apps/<app>/` layout there is no
129
+ # `<org>` segment, so a manifest with no `orgSlug` leaves the org
130
+ # unresolvable — and `oxy publish` needs it to look the project up on the
131
+ # build-config endpoint.
132
+ org="$(jq -r '.orgSlug // ""' "$root/$manifest")"
133
+ nested=0
134
+ case "$app_dir" in
135
+ apps/*/*) nested=1 ;;
136
+ esac
137
+ if { [ -z "$org" ] || [ "$org" = "null" ]; } && [ "$nested" -eq 0 ]; then
138
+ printf '::error file=%s::%s has no "orgSlug", and %s is not an apps/<org>/<app>/ path for oxy publish to infer one from. Add "orgSlug" to the manifest.\n' \
139
+ "$manifest" "$manifest" "$app_dir" >&2
140
+ bad=1
141
+ continue
142
+ fi
143
+
144
+ # `build.outDir`, defaulting to `out` — the same default `oxy publish`
145
+ # applies. Guarded with a type check because a manifest whose `build` is a
146
+ # string (or a number) would make plain `.build.outDir` a jq error, and a jq
147
+ # error under `set -e` would abort the whole scan on one malformed file
148
+ # instead of reporting it.
149
+ out="$(jq -r 'if (.build | type) == "object" then (.build.outDir // "out") else "out" end' "$root/$manifest")"
150
+ if [ -z "$out" ] || [ "$out" = "null" ]; then
151
+ out="out"
152
+ fi
153
+
154
+ printf '%s\t%s\n' "$app_dir" "$out" >> "$buffer"
155
+ count=$((count + 1))
156
+ done <<EOF
157
+ $manifests
158
+ EOF
159
+
160
+ if [ "$bad" -ne 0 ]; then
161
+ exit 1
162
+ fi
163
+
164
+ # Reached only when the repo has at least one app, which is the only state in
165
+ # which a lockfile is required. `pnpm install --frozen-lockfile` fails without
166
+ # one, and it fails with a message about frozen lockfiles rather than about
167
+ # the thing to do, so say the thing to do.
168
+ if [ ! -f "$root/pnpm-lock.yaml" ]; then
169
+ printf '::error::this repo has %s custom app(s) but no pnpm-lock.yaml. "pnpm install --frozen-lockfile" cannot run without one: run "pnpm install" locally and commit the lockfile it writes.\n' \
170
+ "$count" >&2
171
+ exit 1
172
+ fi
173
+
174
+ emit_count "$count"
175
+ cat "$buffer"
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # Which oxy environment does this run publish to?
3
+ #
4
+ # One line of logic, in its own file for the same reason discover-apps.sh is:
5
+ # a `run:` block cannot be executed anywhere but a runner, so anything left
6
+ # inline is unexecuted code until the first real push. This is the mapping the
7
+ # whole dev/prod split rests on, so it is the last thing that should be
8
+ # unexecuted.
9
+ #
10
+ # It also has exactly one caller and one answer ON PURPOSE. The name printed
11
+ # here is used TWICE by publish.yaml — as `oxy publish --env <name>` and as the
12
+ # GitHub Actions `environment:` whose secrets that publish authenticates with —
13
+ # and those two must never disagree. Two ternaries in the YAML would have been
14
+ # shorter and would have drifted the first time one of them was edited; one
15
+ # value, resolved once and read twice, cannot.
16
+ #
17
+ # Contract:
18
+ # $1 the trigger, defaulting to $GITHUB_EVENT_NAME. Passed explicitly
19
+ # only by the tests.
20
+ # stdout the environment name on one line: `dev` or `production`.
21
+ # exit 0 only for a trigger this repo has a mapping for.
22
+ # exit 1 for anything else, INCLUDING an empty trigger.
23
+ #
24
+ # The refusal is the point. `oxy publish` defaults `--env` to **production**,
25
+ # so a workflow that omits the flag ships a customer's live environment while
26
+ # looking like it did nothing in particular — which is exactly what this repo's
27
+ # CI did before this file existed. Every publish now names its environment out
28
+ # loud, and a trigger nobody has thought about fails here rather than falling
29
+ # through to the most dangerous of the two.
30
+
31
+ set -euo pipefail
32
+
33
+ trigger="${1:-${GITHUB_EVENT_NAME:-}}"
34
+
35
+ case "$trigger" in
36
+ # A merge to main. Draft, to the dev workspace: main moving is not a decision
37
+ # to ship anything to a customer.
38
+ push)
39
+ printf 'dev\n'
40
+ ;;
41
+ # Someone stood at the Actions tab, picked a ref and pressed the button. That
42
+ # is the deliberate act, so that is the one that reaches production.
43
+ workflow_dispatch)
44
+ printf 'production\n'
45
+ ;;
46
+ '')
47
+ printf '::error::no trigger to map to an oxy environment: GITHUB_EVENT_NAME is empty and no argument was given.\n' >&2
48
+ exit 1
49
+ ;;
50
+ *)
51
+ printf '::error::this repo publishes on "push" and "workflow_dispatch" only, and has no oxy environment mapped for "%s". Add one to .github/scripts/publish-env.sh — deliberately, because the alternative is a trigger nobody reviewed publishing to production, which is what "oxy publish" does when nothing names an environment.\n' \
52
+ "$trigger" >&2
53
+ exit 1
54
+ ;;
55
+ esac