@devdogsuga/devtools 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.
- package/README.md +44 -0
- package/bin/devtools-ci-bare.mjs +48 -0
- package/bin/devtools-ci.mjs +17 -0
- package/bin/devtools.mjs +27 -0
- package/dist/args.d.ts +26 -0
- package/dist/args.js +78 -0
- package/dist/bws/bw.d.ts +9 -0
- package/dist/bws/bw.js +49 -0
- package/dist/bws/client.d.ts +43 -0
- package/dist/bws/client.js +363 -0
- package/dist/bws/environments.d.ts +96 -0
- package/dist/bws/environments.js +111 -0
- package/dist/bws/pace.d.ts +27 -0
- package/dist/bws/pace.js +43 -0
- package/dist/bws/retry.d.ts +12 -0
- package/dist/bws/retry.js +39 -0
- package/dist/bws/store.d.ts +1 -0
- package/dist/bws/store.js +32 -0
- package/dist/bws/token.d.ts +31 -0
- package/dist/bws/token.js +88 -0
- package/dist/bws/vault.d.ts +29 -0
- package/dist/bws/vault.js +200 -0
- package/dist/catalog.d.ts +55 -0
- package/dist/catalog.js +81 -0
- package/dist/cf/commands.d.ts +1 -0
- package/dist/cf/commands.js +182 -0
- package/dist/cf/local-env.d.ts +44 -0
- package/dist/cf/local-env.js +148 -0
- package/dist/ci.d.ts +1 -0
- package/dist/ci.js +319 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +1077 -0
- package/dist/commands.d.ts +255 -0
- package/dist/commands.js +1279 -0
- package/dist/completions.d.ts +4 -0
- package/dist/completions.js +110 -0
- package/dist/cron/commands.d.ts +47 -0
- package/dist/cron/commands.js +372 -0
- package/dist/cron/discovery.d.ts +55 -0
- package/dist/cron/discovery.js +85 -0
- package/dist/cron/schema.d.ts +45 -0
- package/dist/cron/schema.js +35 -0
- package/dist/db/config-push.d.ts +8 -0
- package/dist/db/config-push.js +11 -0
- package/dist/db/connection.d.ts +79 -0
- package/dist/db/connection.js +114 -0
- package/dist/db/exec.d.ts +1 -0
- package/dist/db/exec.js +11 -0
- package/dist/db/generate-migration.d.ts +1 -0
- package/dist/db/generate-migration.js +67 -0
- package/dist/db/generate-types.d.ts +1 -0
- package/dist/db/generate-types.js +4 -0
- package/dist/db/introspect.d.ts +1 -0
- package/dist/db/introspect.js +113 -0
- package/dist/db/local-env.d.ts +65 -0
- package/dist/db/local-env.js +23 -0
- package/dist/db/new-migration.d.ts +1 -0
- package/dist/db/new-migration.js +8 -0
- package/dist/db/run.d.ts +70 -0
- package/dist/db/run.js +158 -0
- package/dist/db/seed-buckets.d.ts +2 -0
- package/dist/db/seed-buckets.js +4 -0
- package/dist/db/seed-production.d.ts +8 -0
- package/dist/db/seed-production.js +73 -0
- package/dist/db/seed-roles.d.ts +7 -0
- package/dist/db/seed-roles.js +31 -0
- package/dist/db/session-refresh.d.ts +43 -0
- package/dist/db/session-refresh.js +40 -0
- package/dist/deploy/migrations.d.ts +20 -0
- package/dist/deploy/migrations.js +68 -0
- package/dist/deploy/orphans.d.ts +20 -0
- package/dist/deploy/orphans.js +177 -0
- package/dist/deploy/preflight.d.ts +53 -0
- package/dist/deploy/preflight.js +189 -0
- package/dist/deploy/report.d.ts +26 -0
- package/dist/deploy/report.js +70 -0
- package/dist/deploy/require-planner.d.ts +2 -0
- package/dist/deploy/require-planner.js +60 -0
- package/dist/deploy/require-token.d.ts +15 -0
- package/dist/deploy/require-token.js +78 -0
- package/dist/deploy/secrets-file.d.ts +17 -0
- package/dist/deploy/secrets-file.js +167 -0
- package/dist/deploy/write-env.d.ts +40 -0
- package/dist/deploy/write-env.js +393 -0
- package/dist/docs/index-pages.d.ts +36 -0
- package/dist/docs/index-pages.js +184 -0
- package/dist/doctor.d.ts +62 -0
- package/dist/doctor.js +225 -0
- package/dist/emails/commands.d.ts +15 -0
- package/dist/emails/commands.js +130 -0
- package/dist/emails/email-types.d.ts +13 -0
- package/dist/emails/email-types.js +1 -0
- package/dist/emails/fixtures.d.ts +20 -0
- package/dist/emails/fixtures.js +20 -0
- package/dist/env/audit.d.ts +206 -0
- package/dist/env/audit.js +436 -0
- package/dist/env/cloudflare.d.ts +21 -0
- package/dist/env/cloudflare.js +61 -0
- package/dist/env/commands.d.ts +99 -0
- package/dist/env/commands.js +612 -0
- package/dist/env/discovery.d.ts +15 -0
- package/dist/env/discovery.js +226 -0
- package/dist/env/document.d.ts +119 -0
- package/dist/env/document.js +323 -0
- package/dist/env/example.d.ts +68 -0
- package/dist/env/example.js +603 -0
- package/dist/env/selection.d.ts +105 -0
- package/dist/env/selection.js +226 -0
- package/dist/environment.d.ts +92 -0
- package/dist/environment.js +206 -0
- package/dist/fingerprint.d.ts +10 -0
- package/dist/fingerprint.js +18 -0
- package/dist/gen/campus-map.d.ts +1 -0
- package/dist/gen/campus-map.js +473 -0
- package/dist/gen/commands.d.ts +1 -0
- package/dist/gen/commands.js +25 -0
- package/dist/gen/hypno.d.ts +1 -0
- package/dist/gen/hypno.js +134 -0
- package/dist/gh/client.d.ts +80 -0
- package/dist/gh/client.js +258 -0
- package/dist/gh/environments.d.ts +83 -0
- package/dist/gh/environments.js +178 -0
- package/dist/gh/rulesets/actors.d.ts +14 -0
- package/dist/gh/rulesets/actors.js +68 -0
- package/dist/gh/rulesets/api.d.ts +15 -0
- package/dist/gh/rulesets/api.js +99 -0
- package/dist/gh/rulesets/commands.d.ts +3 -0
- package/dist/gh/rulesets/commands.js +203 -0
- package/dist/gh/rulesets/desired.d.ts +112 -0
- package/dist/gh/rulesets/desired.js +247 -0
- package/dist/gh/rulesets/diff.d.ts +65 -0
- package/dist/gh/rulesets/diff.js +134 -0
- package/dist/gh/rulesets/types.d.ts +76 -0
- package/dist/gh/rulesets/types.js +13 -0
- package/dist/gh/settings/api.d.ts +40 -0
- package/dist/gh/settings/api.js +172 -0
- package/dist/gh/settings/commands.d.ts +1 -0
- package/dist/gh/settings/commands.js +245 -0
- package/dist/gh/settings/desired.d.ts +53 -0
- package/dist/gh/settings/desired.js +39 -0
- package/dist/gh/settings/diff.d.ts +57 -0
- package/dist/gh/settings/diff.js +156 -0
- package/dist/gh/settings/types.d.ts +92 -0
- package/dist/gh/settings/types.js +11 -0
- package/dist/gh/settings/workflows.d.ts +41 -0
- package/dist/gh/settings/workflows.js +124 -0
- package/dist/grantRoot.d.ts +42 -0
- package/dist/grantRoot.js +89 -0
- package/dist/help.d.ts +18 -0
- package/dist/help.js +169 -0
- package/dist/images/commands.d.ts +35 -0
- package/dist/images/commands.js +233 -0
- package/dist/images/events.d.ts +36 -0
- package/dist/images/events.js +152 -0
- package/dist/images/graphics.d.ts +70 -0
- package/dist/images/graphics.js +233 -0
- package/dist/images/open-graph-types.d.ts +75 -0
- package/dist/images/open-graph-types.js +1 -0
- package/dist/images/prompts.d.ts +34 -0
- package/dist/images/prompts.js +100 -0
- package/dist/images/render.d.ts +21 -0
- package/dist/images/render.js +57 -0
- package/dist/images/select.d.ts +64 -0
- package/dist/images/select.js +96 -0
- package/dist/instance.d.ts +67 -0
- package/dist/instance.js +129 -0
- package/dist/invocation.d.ts +49 -0
- package/dist/invocation.js +78 -0
- package/dist/launch-ci.d.ts +1 -0
- package/dist/launch-ci.js +86 -0
- package/dist/launch.d.ts +29 -0
- package/dist/launch.js +371 -0
- package/dist/menu.d.ts +31 -0
- package/dist/menu.js +277 -0
- package/dist/newsletter/commands.d.ts +16 -0
- package/dist/newsletter/commands.js +311 -0
- package/dist/newsletter/imap.d.ts +17 -0
- package/dist/newsletter/imap.js +127 -0
- package/dist/newsletter/loopback.d.ts +24 -0
- package/dist/newsletter/loopback.js +100 -0
- package/dist/newsletter/oauth.d.ts +26 -0
- package/dist/newsletter/oauth.js +130 -0
- package/dist/newsletter/smtp.d.ts +23 -0
- package/dist/newsletter/smtp.js +133 -0
- package/dist/oauth/browser.d.ts +1 -0
- package/dist/oauth/browser.js +13 -0
- package/dist/oauth/config.d.ts +24 -0
- package/dist/oauth/config.js +24 -0
- package/dist/oauth/connect-url.d.ts +17 -0
- package/dist/oauth/connect-url.js +23 -0
- package/dist/oauth/db.d.ts +37 -0
- package/dist/oauth/db.js +108 -0
- package/dist/oauth/device.d.ts +93 -0
- package/dist/oauth/device.js +190 -0
- package/dist/oauth/discovery.d.ts +28 -0
- package/dist/oauth/discovery.js +54 -0
- package/dist/oauth/env-file.d.ts +6 -0
- package/dist/oauth/env-file.js +35 -0
- package/dist/oauth/exchange.d.ts +43 -0
- package/dist/oauth/exchange.js +87 -0
- package/dist/oauth/label.d.ts +1 -0
- package/dist/oauth/label.js +25 -0
- package/dist/oauth/loopback.d.ts +52 -0
- package/dist/oauth/loopback.js +162 -0
- package/dist/oauth/pkce.d.ts +6 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/target.d.ts +54 -0
- package/dist/oauth/target.js +102 -0
- package/dist/oauth/transport.d.ts +41 -0
- package/dist/oauth/transport.js +32 -0
- package/dist/oauth/wizard.d.ts +33 -0
- package/dist/oauth/wizard.js +571 -0
- package/dist/pick.d.ts +6 -0
- package/dist/pick.js +64 -0
- package/dist/planner/checks.d.ts +26 -0
- package/dist/planner/checks.js +104 -0
- package/dist/planner/commands.d.ts +10 -0
- package/dist/planner/commands.js +367 -0
- package/dist/planner/db.d.ts +15 -0
- package/dist/planner/db.js +33 -0
- package/dist/planner/role.d.ts +33 -0
- package/dist/planner/role.js +68 -0
- package/dist/repo/peers.d.ts +24 -0
- package/dist/repo/peers.js +114 -0
- package/dist/repo/preflight.d.ts +109 -0
- package/dist/repo/preflight.js +266 -0
- package/dist/repo/resolve.d.ts +41 -0
- package/dist/repo/resolve.js +152 -0
- package/dist/repo/root.d.ts +23 -0
- package/dist/repo/root.js +91 -0
- package/dist/repo/source.d.ts +14 -0
- package/dist/repo/source.js +54 -0
- package/dist/repo/tsx-loader.d.ts +6 -0
- package/dist/repo/tsx-loader.js +41 -0
- package/dist/run/pick.d.ts +60 -0
- package/dist/run/pick.js +559 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +152 -0
- package/dist/stack.d.ts +48 -0
- package/dist/stack.js +290 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +93 -0
- package/dist/tier.d.ts +40 -0
- package/dist/tier.js +93 -0
- package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
- package/dist/ui.d.ts +25 -0
- package/dist/ui.js +51 -0
- package/dist/workers.d.ts +5 -0
- package/dist/workers.js +35 -0
- package/dist/workflows/commands.d.ts +44 -0
- package/dist/workflows/commands.js +668 -0
- package/env.ts +158 -0
- package/package.json +80 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `devtools deploy write-env [--source <manifest>]`
|
|
3
|
+
*
|
|
4
|
+
* Materialises `.env.staging` / `.env.production` inside a deploy job.
|
|
5
|
+
*
|
|
6
|
+
* Everything downstream of this command reads its configuration through
|
|
7
|
+
* `with-env`, which reads a file on disk: `cf:build:*`, `cf:deploy:*`,
|
|
8
|
+
* `deploy:*`, `deploy secrets-file`. CI has no such file, so one step has to
|
|
9
|
+
* write it. This is that step, and it is the ONLY place in
|
|
10
|
+
* the pipeline that touches the `secrets` and `vars` contexts. After it runs, a
|
|
11
|
+
* deploy job looks exactly like a devops laptop, and every later step can be
|
|
12
|
+
* the same command a person would run by hand.
|
|
13
|
+
*
|
|
14
|
+
* ## ⚠️ This command cannot be run through `with-env`
|
|
15
|
+
*
|
|
16
|
+
* `pnpm devtools` is `with-env tsx src/cli.ts`, and `with-env` refuses to
|
|
17
|
+
* start when the file for `DEPLOY_ENV` is absent. That file is the one this
|
|
18
|
+
* command is about to create, so routing it the ordinary way is a bootstrap
|
|
19
|
+
* cycle: it would die on `MissingEnvFileError` for its own output. Use the
|
|
20
|
+
* package's `cli:no-env` script instead,
|
|
21
|
+
*
|
|
22
|
+
* pnpm --filter @devdogsuga/devtools run cli:no-env deploy write-env
|
|
23
|
+
*
|
|
24
|
+
* the same entry point with the `with-env` wrapper removed, and how
|
|
25
|
+
* `deploy.yaml` invokes it. (`pnpm devtools env example --check` already routes
|
|
26
|
+
* through `cli:no-env` for the mirror-image reason: it must run in a job that
|
|
27
|
+
* deliberately has no env file.) Nothing here reads the ambient environment
|
|
28
|
+
* apart from `DEPLOY_ENV` and the two context variables the workflow sets on
|
|
29
|
+
* the step, so there is nothing for `with-env` to have supplied.
|
|
30
|
+
*
|
|
31
|
+
* ## Where the values come from
|
|
32
|
+
*
|
|
33
|
+
* secret `${{ secrets.* }}` for the environment, written by a human
|
|
34
|
+
* running `pnpm devtools env push`. CI never reads Bitwarden.
|
|
35
|
+
* variable `${{ vars.* }}`, the per-environment values that are NOT
|
|
36
|
+
* secrets (`PROJECT_REF`, `BASE_URL`, `PUBLISHABLE_KEY`, …).
|
|
37
|
+
* derived an `example` metadata entry that is a `$VAR` formula rather than
|
|
38
|
+
* a placeholder: `API_URL` is `https://$PROJECT_REF.supabase.co`
|
|
39
|
+
* and always has been. Security plan §A.4: "derived at deploy
|
|
40
|
+
* time from PROJECT_REF, never stored".
|
|
41
|
+
* committed a `scope: "default"` value, identical in every environment and
|
|
42
|
+
* carried in the registry (`GITHUB_ORG`, the avatars bucket).
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ A literal `example` on a `scope: "environment"` key is NOT a fallback.
|
|
45
|
+
* `BASE_URL`'s example is `http://localhost:3000` and `GH_APP_ID`'s is
|
|
46
|
+
* `000000`: both are the shape of a working development file, and taking
|
|
47
|
+
* either as a deploy default would ship a production Worker pointed at
|
|
48
|
+
* localhost with nothing anywhere saying so. Only a `$`-formula (a derivation)
|
|
49
|
+
* and a `scope: "default"` constant qualify, which is why the two cases are
|
|
50
|
+
* separated above rather than collapsed into "use the example".
|
|
51
|
+
*
|
|
52
|
+
* ## The completeness check is the point
|
|
53
|
+
*
|
|
54
|
+
* Security plan §A.6: the deploy build needs the ENTIRE server schema, because
|
|
55
|
+
* `@t3-oss/env` validates the whole thing on import and the platform
|
|
56
|
+
* prerenders pages that resolve their gate at build time. So the interesting
|
|
57
|
+
* failure is a missing value, not a wrong one. GitHub hands a missing secret to
|
|
58
|
+
* a workflow as an EMPTY STRING rather than an error (§A.7's closing warning).
|
|
59
|
+
*
|
|
60
|
+
* Rather than trust that, this refuses to write a file that would fail the
|
|
61
|
+
* build, and names every key it could not resolve along with where that key
|
|
62
|
+
* was supposed to come from. "Required" is derived, not listed: a schema that
|
|
63
|
+
* rejects `undefined` is required, one with `.optional()` or `.default()` is
|
|
64
|
+
* not. That is the same registry the apps validate against, so the two cannot
|
|
65
|
+
* drift.
|
|
66
|
+
*
|
|
67
|
+
* ## Refusals
|
|
68
|
+
*
|
|
69
|
+
* `neverStoreKeys()` appearing in either context is a hard failure. It is
|
|
70
|
+
* mostly unreachable, since `env push` will not upload one, but this is the
|
|
71
|
+
* last place to catch a `BWS_ACCESS_TOKEN` added to the repository by hand,
|
|
72
|
+
* and the cost of missing it is every secret the project holds (see the long
|
|
73
|
+
* comment on the declaration in `packages/devtools/env.ts`).
|
|
74
|
+
*
|
|
75
|
+
* Writing uses `{ flag: "wx" }`, the same atomic refusal `env init` uses.
|
|
76
|
+
* On a runner the file never exists; on a laptop it is the devops copy of the
|
|
77
|
+
* production credentials, and a stray run of this command must not be able to
|
|
78
|
+
* overwrite it. `development` is refused outright for the same reason: `.env`
|
|
79
|
+
* is that file.
|
|
80
|
+
*
|
|
81
|
+
* ## `--source <manifest>`
|
|
82
|
+
*
|
|
83
|
+
* Composes only what one manifest declares, and demands ALL of its secrets
|
|
84
|
+
* rather than only its schema-required keys. That is for a step that exists to
|
|
85
|
+
* mutate one thing. `supabase config push` reads `supabase/env.ts` and nothing
|
|
86
|
+
* else, the job runs in a GitHub environment holding a deliberately narrow
|
|
87
|
+
* slice of the credentials, and the interesting failure is an OAuth provider
|
|
88
|
+
* reconfigured without its secret. Every one of those keys is `.optional()` in
|
|
89
|
+
* the schema (an app boots fine without them; only `config.toml` reads them),
|
|
90
|
+
* so schema-required alone would let the push through with a provider
|
|
91
|
+
* half-configured.
|
|
92
|
+
*/
|
|
93
|
+
import { writeFileSync } from "node:fs";
|
|
94
|
+
import { join } from "node:path";
|
|
95
|
+
import { assertRegistryLoaded } from "../env/discovery.js";
|
|
96
|
+
import { getEnvSync } from "../repo/peers.js";
|
|
97
|
+
import { findRepoRoot } from "../repo/root.js";
|
|
98
|
+
import { DeployError, summary } from "./report.js";
|
|
99
|
+
/**
|
|
100
|
+
* `${{ toJSON(secrets) }}` and `${{ toJSON(vars) }}` arrive as JSON objects.
|
|
101
|
+
*
|
|
102
|
+
* Non-string members are dropped rather than coerced: the contexts only ever
|
|
103
|
+
* hold strings, and a `String(value)` here would turn a future shape change
|
|
104
|
+
* into a value like `[object Object]` written to a Worker.
|
|
105
|
+
*/
|
|
106
|
+
function readContext(name, env) {
|
|
107
|
+
const raw = env[name];
|
|
108
|
+
if (raw === undefined || raw.trim() === "")
|
|
109
|
+
return {};
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(raw);
|
|
113
|
+
}
|
|
114
|
+
catch (cause) {
|
|
115
|
+
throw new DeployError(`${name} is not valid JSON.`, [String(cause)]);
|
|
116
|
+
}
|
|
117
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
118
|
+
throw new DeployError(`${name} is not a JSON object.`);
|
|
119
|
+
}
|
|
120
|
+
const out = {};
|
|
121
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
122
|
+
// An empty value is treated as absent throughout this toolchain: it reads
|
|
123
|
+
// as "configured" to every consumer that checks for presence, which is
|
|
124
|
+
// worse than nothing being there. Same rule as `selectForPush`.
|
|
125
|
+
if (typeof value === "string" && value !== "")
|
|
126
|
+
out[key] = value;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The value the registry itself supplies, or `null` when it supplies none.
|
|
132
|
+
*
|
|
133
|
+
* The derivation half is `derivationOf()` in `@devdogsuga/env` rather than a
|
|
134
|
+
* test written here, because `env init --target` has to make the identical
|
|
135
|
+
* call: the file a person fills in by hand and the file this command composes
|
|
136
|
+
* on a runner are the same file, and a second opinion about which `example`
|
|
137
|
+
* values are real would make them differ. See that module for what each of its
|
|
138
|
+
* gates is closing, chiefly `DB_URL`, whose example is a genuine
|
|
139
|
+
* `$PROJECT_REF` derivation with `<password>` and `<host>` punched into it.
|
|
140
|
+
*/
|
|
141
|
+
function fromRegistry(meta) {
|
|
142
|
+
const derivation = getEnvSync().derivationOf(meta);
|
|
143
|
+
if (derivation !== null)
|
|
144
|
+
return { value: derivation, from: "derived" };
|
|
145
|
+
const example = meta.example;
|
|
146
|
+
if (example === undefined || example === "")
|
|
147
|
+
return null;
|
|
148
|
+
return meta.scope === "default" && meta.secrecy === "public"
|
|
149
|
+
? { value: example, from: "committed" }
|
|
150
|
+
: null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The one expansion failure with a second meaning. For a REQUIRED key it is a
|
|
154
|
+
* fault like any other `DeployError`; for an optional one it is the registry
|
|
155
|
+
* saying "leave it unset until the deploy exists" (STUDY_GROUP_FINDER_URL and
|
|
156
|
+
* its `_CALLBACK`, declared ahead of any web deployment), so the caller skips
|
|
157
|
+
* the key instead. A distinct class rather than a message match, because the
|
|
158
|
+
* write loop's catch must never swallow a cycle or a quoting refusal.
|
|
159
|
+
*/
|
|
160
|
+
class MissingSourceError extends DeployError {
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Expands `$NAME` / `${NAME}` against the values resolved so far.
|
|
164
|
+
*
|
|
165
|
+
* dotenvx would do this at load time and the composed file could carry the
|
|
166
|
+
* formula, but only for an unquoted or double-quoted value, and quoting is what
|
|
167
|
+
* this file cannot compromise on (see `quote` below). So the derivations are
|
|
168
|
+
* resolved here and every line ships fully literal: one mechanism, verifiable
|
|
169
|
+
* by reading the file, with no dependence on which of dotenvx's three quoting
|
|
170
|
+
* modes a value landed in.
|
|
171
|
+
*/
|
|
172
|
+
function expand(key, value, resolved, seen = []) {
|
|
173
|
+
return value.replace(/\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g, (_, a, b) => {
|
|
174
|
+
const name = (a ?? b);
|
|
175
|
+
if (seen.includes(name)) {
|
|
176
|
+
throw new DeployError(`${key} derives from ${name}, which derives back from it.`, [`chain: ${[...seen, name].join(" -> ")}`]);
|
|
177
|
+
}
|
|
178
|
+
const target = resolved.get(name);
|
|
179
|
+
if (!target) {
|
|
180
|
+
throw new MissingSourceError(`${key} is derived from ${name}, which has no value.`, [`${name} is the value to fix; ${key} follows from it.`]);
|
|
181
|
+
}
|
|
182
|
+
return expand(name, target.value, resolved, [...seen, name]);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Single quotes, because dotenvx treats a single-quoted value as fully
|
|
187
|
+
* literal: no `$` expansion, no backslash escapes, and multi-line values (the
|
|
188
|
+
* GitHub App private key) survive verbatim. Verified against dotenvx 2.19 on
|
|
189
|
+
* 2026-08-15, including embedded double quotes, `#`, and a bare `'`.
|
|
190
|
+
*
|
|
191
|
+
* The one shape it cannot express is a value whose first or last character is
|
|
192
|
+
* a quote, since the parser takes everything to the outermost one. That is
|
|
193
|
+
* refused rather than mangled: a credential silently truncated by one
|
|
194
|
+
* character fails at the far end of the pipeline, against a service that will
|
|
195
|
+
* only say "unauthorized".
|
|
196
|
+
*/
|
|
197
|
+
function quote(key, value) {
|
|
198
|
+
if (value.startsWith("'") || value.endsWith("'")) {
|
|
199
|
+
throw new DeployError(`${key} begins or ends with a single quote, which cannot be quoted.`, ["Re-issue the credential, or wrap it (base64) at both ends."]);
|
|
200
|
+
}
|
|
201
|
+
return `'${value}'`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Composes the file and writes it.
|
|
205
|
+
*
|
|
206
|
+
* Every refusal is a `DeployError`, and every one of them ALSO lands in the job
|
|
207
|
+
* summary, via the wrapper below. That is deliberate here and not for the other
|
|
208
|
+
* commands in the group: the failure mode this exists to catch is a missing
|
|
209
|
+
* credential, and the person who has to add it is reading the run page rather
|
|
210
|
+
* than a step's log.
|
|
211
|
+
*/
|
|
212
|
+
export async function runDeployWriteEnv(options = {}) {
|
|
213
|
+
const env = options.env ?? process.env;
|
|
214
|
+
try {
|
|
215
|
+
return compose(options, env);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (err instanceof DeployError) {
|
|
219
|
+
summary(["### Deploy environment could not be composed", "", err.message, ""], env);
|
|
220
|
+
if (err.detail.length > 0) {
|
|
221
|
+
summary(["```", ...err.detail, "```", ""], env);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function compose(options, env) {
|
|
228
|
+
assertRegistryLoaded();
|
|
229
|
+
const root = options.root ?? findRepoRoot();
|
|
230
|
+
const source = options.source ?? null;
|
|
231
|
+
const environment = getEnvSync().resolveEnvironment(env.DEPLOY_ENV);
|
|
232
|
+
if (environment === "development") {
|
|
233
|
+
throw new DeployError("DEPLOY_ENV must name a deployed environment (staging | production).", [
|
|
234
|
+
"development means the root .env, which is a real file with real",
|
|
235
|
+
"credentials in it. This command never writes that one.",
|
|
236
|
+
]);
|
|
237
|
+
}
|
|
238
|
+
const ghSecrets = readContext("DEPLOY_GITHUB_SECRETS", env);
|
|
239
|
+
const ghVars = readContext("DEPLOY_GITHUB_VARS", env);
|
|
240
|
+
const smuggled = getEnvSync().neverStoreKeys().filter((k) => k in ghSecrets || k in ghVars);
|
|
241
|
+
if (smuggled.length > 0) {
|
|
242
|
+
throw new DeployError(`${smuggled.join(", ")} must never be a GitHub secret or variable.`, [
|
|
243
|
+
"Delete it from the repository and the environment, then rotate it.",
|
|
244
|
+
"See the declaration in packages/devtools/env.ts for why.",
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
const resolved = new Map();
|
|
248
|
+
const missing = [];
|
|
249
|
+
/** Which keys the write loop may NOT silently drop. See MissingSourceError. */
|
|
250
|
+
const requiredKeys = new Set();
|
|
251
|
+
for (const [key, entries] of getEnvSync().variables()) {
|
|
252
|
+
const meta = entries[0].meta;
|
|
253
|
+
// One contributor's own value, meaningless anywhere else and required by
|
|
254
|
+
// nothing. An app that could not boot without one could not boot in CI.
|
|
255
|
+
if (meta.scope === "developer")
|
|
256
|
+
continue;
|
|
257
|
+
// `:tooling` suffixes count as the same manifest. The split says "declared
|
|
258
|
+
// here but not read by the running thing", a distinction about the Worker,
|
|
259
|
+
// not about which step needs the value.
|
|
260
|
+
const owned = source === null ||
|
|
261
|
+
entries.some((e) => e.source === source || e.source.startsWith(`${source}:`));
|
|
262
|
+
if (!owned)
|
|
263
|
+
continue;
|
|
264
|
+
const asSecret = ghSecrets[key];
|
|
265
|
+
const asVariable = ghVars[key];
|
|
266
|
+
// GitHub allows a secret and a variable of the same name in one environment
|
|
267
|
+
// and never says which one it means. Refused: which of the two a deploy used
|
|
268
|
+
// would depend on this file's precedence rules, and nothing at the far end
|
|
269
|
+
// could report which value it got.
|
|
270
|
+
if (asSecret !== undefined && asVariable !== undefined) {
|
|
271
|
+
throw new DeployError(`${key} is set BOTH as a secret and as a variable.`, [
|
|
272
|
+
"Delete whichever is wrong. A secret is the right home for anything",
|
|
273
|
+
'the registry marks `secrecy: "secret"`; everything else is a variable.',
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
const value = asSecret !== undefined
|
|
277
|
+
? { value: asSecret, from: "secret" }
|
|
278
|
+
: asVariable !== undefined
|
|
279
|
+
? { value: asVariable, from: "variable" }
|
|
280
|
+
: fromRegistry(meta);
|
|
281
|
+
// `undefined` is rejected by at least one manifest's schema, so omitting the
|
|
282
|
+
// key breaks that app's boot. Derived rather than listed: the schemas are
|
|
283
|
+
// the same ones the build validates against, so this cannot drift from them.
|
|
284
|
+
// Under `--source`, every secret that manifest declares is required too; see
|
|
285
|
+
// the header for why optional-in-schema is not good enough there.
|
|
286
|
+
const required = entries.some((e) => !e.schema.safeParse(undefined).success) ||
|
|
287
|
+
(source !== null && meta.secrecy === "secret");
|
|
288
|
+
if (required)
|
|
289
|
+
requiredKeys.add(key);
|
|
290
|
+
if (!value) {
|
|
291
|
+
if (required)
|
|
292
|
+
missing.push(key);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
resolved.set(key, value);
|
|
296
|
+
}
|
|
297
|
+
if (missing.length > 0) {
|
|
298
|
+
throw new DeployError(`${missing.length} required variable(s) have no value.`, [
|
|
299
|
+
...missing.map((key) => {
|
|
300
|
+
const meta = getEnvSync().variables().get(key)[0].meta;
|
|
301
|
+
return meta.secrecy === "secret"
|
|
302
|
+
? `${key} — a secret: \`pnpm devtools env push\` sends it`
|
|
303
|
+
: `${key} — not a secret: it belongs in the environment's VARIABLES`;
|
|
304
|
+
}),
|
|
305
|
+
"",
|
|
306
|
+
'`env push` syncs both stores: `secrecy: "secret"` keys to the',
|
|
307
|
+
"environment's secrets, the public per-environment ones (PROJECT_REF,",
|
|
308
|
+
"BASE_URL, PUBLISHABLE_KEY, …) to its variables. A key missing here was",
|
|
309
|
+
"either never in the pusher's .env or never pushed — run",
|
|
310
|
+
"`pnpm devtools env audit --target <target>` to see which.",
|
|
311
|
+
]);
|
|
312
|
+
}
|
|
313
|
+
// ── write ──────────────────────────────────────────────────────────────────
|
|
314
|
+
const file = getEnvSync().fileFor(environment);
|
|
315
|
+
const lines = [
|
|
316
|
+
`# ${file} — composed by \`devtools deploy write-env\` for a deploy job.`,
|
|
317
|
+
"#",
|
|
318
|
+
"# NOT a file anybody should have on a laptop. It is written into a runner's",
|
|
319
|
+
"# checkout from the GitHub environment's secrets and variables, read once by",
|
|
320
|
+
"# with-env, and discarded with the runner. Every value is single-quoted and",
|
|
321
|
+
"# literal: the $VAR derivations were already expanded when this was written.",
|
|
322
|
+
"",
|
|
323
|
+
];
|
|
324
|
+
const provenance = new Map();
|
|
325
|
+
for (const [key, entry] of resolved) {
|
|
326
|
+
let value;
|
|
327
|
+
if (entry.from === "derived") {
|
|
328
|
+
try {
|
|
329
|
+
value = expand(key, entry.value, resolved);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
// An optional derivation whose source has no value is a key the
|
|
333
|
+
// registry declared ahead of its deployment, not a fault: omit it,
|
|
334
|
+
// exactly as a hand-filled .env leaves it commented out. A required
|
|
335
|
+
// key keeps the loud failure, naming the source to fix.
|
|
336
|
+
if (error instanceof MissingSourceError && !requiredKeys.has(key)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
value = entry.value;
|
|
344
|
+
}
|
|
345
|
+
lines.push(`${key}=${quote(key, value)}`);
|
|
346
|
+
provenance.set(key, entry.from);
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
writeFileSync(join(root, file), `${lines.join("\n")}\n`, {
|
|
350
|
+
flag: "wx",
|
|
351
|
+
mode: 0o600,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch (cause) {
|
|
355
|
+
// `wx` is the refusal, so EEXIST is the expected shape of "you ran this on
|
|
356
|
+
// a machine that already has the file" rather than an unexplained crash.
|
|
357
|
+
// On a runner it cannot happen; on a laptop the file it declined to touch
|
|
358
|
+
// holds the live credentials for that environment.
|
|
359
|
+
if (cause.code === "EEXIST") {
|
|
360
|
+
throw new DeployError(`${file} already exists, and this never overwrites one.`, [
|
|
361
|
+
"It composes a throwaway file for a runner from the GitHub",
|
|
362
|
+
"environment's secrets and variables. An existing copy is somebody's",
|
|
363
|
+
"real one — `pnpm devtools env pull --target <target>` updates that",
|
|
364
|
+
"in place instead.",
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
throw cause;
|
|
368
|
+
}
|
|
369
|
+
return { file, provenance };
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The line the job log gets: names and provenance only.
|
|
373
|
+
*
|
|
374
|
+
* Never a value, and never a fingerprint either. A fingerprint helps when
|
|
375
|
+
* comparing two stores by hand, and there is nothing here to compare against.
|
|
376
|
+
*/
|
|
377
|
+
export function renderWriteEnvReport(result) {
|
|
378
|
+
const counts = {
|
|
379
|
+
secret: 0,
|
|
380
|
+
variable: 0,
|
|
381
|
+
derived: 0,
|
|
382
|
+
committed: 0,
|
|
383
|
+
};
|
|
384
|
+
for (const from of result.provenance.values())
|
|
385
|
+
counts[from] += 1;
|
|
386
|
+
return [
|
|
387
|
+
`devtools deploy write-env: wrote ${result.file} — ` +
|
|
388
|
+
`${result.provenance.size} variables (${counts.secret} secret, ` +
|
|
389
|
+
`${counts.variable} variable, ${counts.derived} derived, ` +
|
|
390
|
+
`${counts.committed} committed)`,
|
|
391
|
+
...[...result.provenance].map(([key, from]) => ` ${from.padEnd(9)} ${key}`),
|
|
392
|
+
];
|
|
393
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One row of the artifact `@devdogsuga/docs` builds. */
|
|
2
|
+
export interface DocsPage {
|
|
3
|
+
path: string;
|
|
4
|
+
title: string;
|
|
5
|
+
description: string | null;
|
|
6
|
+
plainText: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The seam, kept as narrow as `planner/db.ts`'s.
|
|
10
|
+
*
|
|
11
|
+
* Parameterised rather than that module's `run(text)`: page content is the one
|
|
12
|
+
* thing here that is not a constant, and it is markdown, with apostrophes,
|
|
13
|
+
* dollar-quoting, whatever a contributor writes. Nothing is interpolated.
|
|
14
|
+
*/
|
|
15
|
+
export interface DocsDb {
|
|
16
|
+
run(query: string, params: readonly unknown[]): Promise<unknown[]>;
|
|
17
|
+
end(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export type Connect = (url: string) => DocsDb;
|
|
20
|
+
export declare function connectDocsDb(url: string): DocsDb;
|
|
21
|
+
export declare function isLocalDatabase(url: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Upsert every page by path, then delete the rows whose path is gone.
|
|
24
|
+
*
|
|
25
|
+
* One transaction, and the delete is the reason `--target remote` is explicit:
|
|
26
|
+
* run against a deployed database from a working copy, this replaces the live
|
|
27
|
+
* index with whatever happens to be checked out.
|
|
28
|
+
*/
|
|
29
|
+
export declare function indexPages(db: DocsDb, pages: readonly DocsPage[]): Promise<number>;
|
|
30
|
+
export interface DocsIndexOptions {
|
|
31
|
+
/** "remote" allows a non-local database (replaces the old --force flag). */
|
|
32
|
+
target?: "local" | "remote";
|
|
33
|
+
connect?: Connect;
|
|
34
|
+
load?: () => Promise<readonly DocsPage[]>;
|
|
35
|
+
}
|
|
36
|
+
export declare function runDocsIndex(options?: DocsIndexOptions): Promise<void>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `devtools docs index` writes the documentation search index.
|
|
3
|
+
*
|
|
4
|
+
* ## Why it lives here
|
|
5
|
+
*
|
|
6
|
+
* It was `apps/platform/scripts/index-docs.ts`, and it was the last script in
|
|
7
|
+
* the repository run from more than one place: `pnpm devtools docs index` by a
|
|
8
|
+
* contributor, and `devtools-ci` by BOTH deploy scripts, ahead of every
|
|
9
|
+
* staging and production release. A step the deploy depends on is not an
|
|
10
|
+
* app's private tooling, and the rest of that class (`deploy write-env`,
|
|
11
|
+
* `secrets-file`, `orphans`, `preflight`, `require-token`) moved into this
|
|
12
|
+
* package already, for the reasons `cli.ts` gives.
|
|
13
|
+
*
|
|
14
|
+
* The three that stayed behind in `apps/platform/scripts/` each have exactly
|
|
15
|
+
* one caller and are about the platform's own generated sources:
|
|
16
|
+
* `post-pull.ts` patches what `drizzle-kit pull` emits, `generate-campus-map.ts`
|
|
17
|
+
* is hand-run codegen, and `db seed roles` seeds rows through the
|
|
18
|
+
* platform's own Drizzle schema. Reuse is the line, not subject matter.
|
|
19
|
+
*
|
|
20
|
+
* ## Why raw SQL rather than Drizzle
|
|
21
|
+
*
|
|
22
|
+
* The predecessor imported `~/server/db` and `~/server/db/schema`, a package
|
|
23
|
+
* reaching into an app. That is the wrong direction, and it would have dragged
|
|
24
|
+
* the platform's whole generated schema into a CLI that installs on a fresh
|
|
25
|
+
* clone. Four columns of one table do not need an ORM, and this package
|
|
26
|
+
* already talks to Postgres exactly this way (see `planner/db.ts`).
|
|
27
|
+
*
|
|
28
|
+
* `search` is a generated column, so this writes title/description/plainText
|
|
29
|
+
* and Postgres recomputes the vector.
|
|
30
|
+
*/
|
|
31
|
+
import { confirm, log, spinner } from "@clack/prompts";
|
|
32
|
+
import postgres from "postgres";
|
|
33
|
+
import { bail, errorMessage, explain, unwrap } from "../ui.js";
|
|
34
|
+
import { loadDocs } from "../repo/source.js";
|
|
35
|
+
export function connectDocsDb(url) {
|
|
36
|
+
// `max: 1` is load-bearing, not tuning: the write below is BEGIN, two
|
|
37
|
+
// statements and COMMIT, and they have to land on the same session.
|
|
38
|
+
const sql = postgres(url, { max: 1, prepare: false, connect_timeout: 15 });
|
|
39
|
+
return {
|
|
40
|
+
async run(query, params) {
|
|
41
|
+
return (await sql.unsafe(query, params));
|
|
42
|
+
},
|
|
43
|
+
async end() {
|
|
44
|
+
await sql.end({ timeout: 5 });
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// ── Which database ───────────────────────────────────────────────────────────
|
|
49
|
+
const LOCAL_DB_HOSTS = [
|
|
50
|
+
"localhost",
|
|
51
|
+
"127.0.0.1",
|
|
52
|
+
"[::1]",
|
|
53
|
+
"host.docker.internal",
|
|
54
|
+
];
|
|
55
|
+
export function isLocalDatabase(url) {
|
|
56
|
+
try {
|
|
57
|
+
return LOCAL_DB_HOSTS.includes(new URL(url).hostname);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ── The write ────────────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Upsert every page by path, then delete the rows whose path is gone.
|
|
66
|
+
*
|
|
67
|
+
* One transaction, and the delete is the reason `--target remote` is explicit:
|
|
68
|
+
* run against a deployed database from a working copy, this replaces the live
|
|
69
|
+
* index with whatever happens to be checked out.
|
|
70
|
+
*/
|
|
71
|
+
export async function indexPages(db, pages) {
|
|
72
|
+
const values = pages
|
|
73
|
+
.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`)
|
|
74
|
+
.join(", ");
|
|
75
|
+
const params = pages.flatMap((page) => [
|
|
76
|
+
page.path,
|
|
77
|
+
page.title,
|
|
78
|
+
page.description,
|
|
79
|
+
page.plainText,
|
|
80
|
+
]);
|
|
81
|
+
await db.run("begin", []);
|
|
82
|
+
try {
|
|
83
|
+
await db.run(`insert into platform."docsPages" (path, title, description, "plainText")
|
|
84
|
+
values ${values}
|
|
85
|
+
on conflict (path) do update set
|
|
86
|
+
title = excluded.title,
|
|
87
|
+
description = excluded.description,
|
|
88
|
+
"plainText" = excluded."plainText",
|
|
89
|
+
"updatedAt" = now()`, params);
|
|
90
|
+
await db.run(`delete from platform."docsPages" where path <> all($1::text[])`, [pages.map((page) => page.path)]);
|
|
91
|
+
await db.run("commit", []);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
await db.run("rollback", []);
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
return pages.length;
|
|
98
|
+
}
|
|
99
|
+
// ── The command ──────────────────────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Loads the built artifact.
|
|
102
|
+
*
|
|
103
|
+
* Imported dynamically so that `@devdogsuga/docs` is only needed by the one
|
|
104
|
+
* command that reads it. Every other command in this CLI must not pay for a
|
|
105
|
+
* docs build to start, `setup` most of all: it runs on a clone where nothing
|
|
106
|
+
* is built yet.
|
|
107
|
+
*/
|
|
108
|
+
async function loadPages() {
|
|
109
|
+
const { pages } = (await loadDocs());
|
|
110
|
+
return pages;
|
|
111
|
+
}
|
|
112
|
+
export async function runDocsIndex(options = {}) {
|
|
113
|
+
const connect = options.connect ?? connectDocsDb;
|
|
114
|
+
const load = options.load ?? loadPages;
|
|
115
|
+
const url = process.env.DB_URL;
|
|
116
|
+
if (!url) {
|
|
117
|
+
explain("DB_URL is not set.", "", [
|
|
118
|
+
"Run through `pnpm devtools`, which loads your .env.",
|
|
119
|
+
"`pnpm devtools db start` starts Supabase on this machine and writes one.",
|
|
120
|
+
]);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let pages;
|
|
125
|
+
try {
|
|
126
|
+
pages = await load();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
explain("The docs artifact could not be read.", errorMessage(err), [
|
|
130
|
+
"pnpm --filter @devdogsuga/docs build",
|
|
131
|
+
]);
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (pages.length === 0) {
|
|
136
|
+
explain("The docs artifact is empty.", "", [
|
|
137
|
+
"pnpm --filter @devdogsuga/docs build",
|
|
138
|
+
]);
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Non-local is opt-in because of the delete. `--target remote` is how the
|
|
143
|
+
// deploy scripts say yes; a person gets asked, which the predecessor could
|
|
144
|
+
// not do from a `process.argv` check in a bare script.
|
|
145
|
+
if (!isLocalDatabase(url)) {
|
|
146
|
+
if (options.target !== "remote") {
|
|
147
|
+
if (!process.stdin.isTTY) {
|
|
148
|
+
explain("That is not a local database, and --target remote was not given.", "", [
|
|
149
|
+
"It deletes the rows for pages that no longer exist, so this would",
|
|
150
|
+
"replace the live search index with your working copy.",
|
|
151
|
+
]);
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const go = unwrap(await confirm({
|
|
156
|
+
message: "DB_URL is NOT local. Replace that database's search index with " +
|
|
157
|
+
"this working copy?",
|
|
158
|
+
initialValue: false,
|
|
159
|
+
}));
|
|
160
|
+
if (!go)
|
|
161
|
+
bail("Left the index alone.");
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
log.warn("Indexing a non-local database, as --target remote asked.");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const s = spinner();
|
|
168
|
+
s.start(`Indexing ${pages.length} page(s)`);
|
|
169
|
+
const db = connect(url);
|
|
170
|
+
try {
|
|
171
|
+
const count = await indexPages(db, pages);
|
|
172
|
+
s.stop(`Indexed ${count} page(s)`);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
s.stop("The index was not written");
|
|
176
|
+
explain("Writing the docs index failed.", errorMessage(err), [
|
|
177
|
+
"`pnpm devtools db migrate` applies any migration the table is missing.",
|
|
178
|
+
]);
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await db.end();
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does my app's moderation integration actually work?
|
|
3
|
+
*
|
|
4
|
+
* Two checks, and the second is the one that matters.
|
|
5
|
+
*
|
|
6
|
+
* `conformance()` asks the database what it derived from your schema: is the
|
|
7
|
+
* table addressable, can an author be attributed, does quarantine have a column
|
|
8
|
+
* to write to, can a client still write that column. All of it is answerable
|
|
9
|
+
* from the catalog, and `platform.conformance_check()` does the answering.
|
|
10
|
+
*
|
|
11
|
+
* `quarantineRoundTrip()` answers the question the catalog cannot: *does
|
|
12
|
+
* quarantine do anything?* Quarantine is the only moderation outcome whose
|
|
13
|
+
* effect lives in the app's own policies rather than the platform's, so it is
|
|
14
|
+
* the only one that can be wired up wrong while everything appears to work.
|
|
15
|
+
* The platform records the decision, sets the column, and has no way to notice
|
|
16
|
+
* nobody reads it. The only proof is to file, resolve, and then look.
|
|
17
|
+
*
|
|
18
|
+
* It runs against `platform.profile`, where quarantine means FREEZE rather than
|
|
19
|
+
* hide: the display name is reset to the member's name of record and they lose
|
|
20
|
+
* the ability to change it back. A profile cannot be hidden: rosters, teams
|
|
21
|
+
* and standings all render the name, and the row is own-row-only over PostgREST
|
|
22
|
+
* anyway, so there is nobody to hide it from. See
|
|
23
|
+
* 20260808000000_platform_profile_moderation.sql.
|
|
24
|
+
*
|
|
25
|
+
* Both run as seeded personas over PostgREST rather than as `postgres` over
|
|
26
|
+
* SQL, because RLS is the thing under test and a superuser bypasses it.
|
|
27
|
+
*/
|
|
28
|
+
import { type Instance } from "./instance.js";
|
|
29
|
+
import type { CheckResult } from "./ui.js";
|
|
30
|
+
interface ConformanceType {
|
|
31
|
+
contentType: string;
|
|
32
|
+
/** Schema-qualified, e.g. `platform.profile`. */
|
|
33
|
+
tableName: string;
|
|
34
|
+
checks: CheckResult[];
|
|
35
|
+
}
|
|
36
|
+
/** Runs `platform.conformance_check()` as the seeded moderator. */
|
|
37
|
+
export declare function conformance(instance: Instance, appSlug: string): Promise<ConformanceType[]>;
|
|
38
|
+
/** The apps that have anything to check, for the picker. */
|
|
39
|
+
export declare function listApps(instance: Instance): Promise<string[]>;
|
|
40
|
+
export interface RoundTripStep {
|
|
41
|
+
name: string;
|
|
42
|
+
ok: boolean;
|
|
43
|
+
detail: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Files a report against a profile, resolves it with quarantine, and checks
|
|
47
|
+
* that the freeze and the name reset both landed.
|
|
48
|
+
*
|
|
49
|
+
* The subject is a THROWAWAY member created for the run, not one of the seeded
|
|
50
|
+
* personas, and that is not squeamishness about touching fixtures.
|
|
51
|
+
* `file_report` corroborates an existing open report rather than filing a
|
|
52
|
+
* second one against the same content, and the seeds ship exactly such a report
|
|
53
|
+
* against the author persona's profile. Reusing it would make this function's
|
|
54
|
+
* first step silently become "add a corroboration", and every assertion after
|
|
55
|
+
* it would be reading the seed's decision instead of its own.
|
|
56
|
+
*
|
|
57
|
+
* Everything it creates, it deletes, including on the failure paths, which is
|
|
58
|
+
* why the teardown is in a `finally`. A contributor running this twice should
|
|
59
|
+
* get the same answer the second time.
|
|
60
|
+
*/
|
|
61
|
+
export declare function quarantineRoundTrip(instance: Instance): Promise<RoundTripStep[]>;
|
|
62
|
+
export {};
|