@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
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
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 { adminClient, personaClient, PERSONA_PASSWORD, PERSONAS, } from "./instance.js";
|
|
29
|
+
/** Runs `platform.conformance_check()` as the seeded moderator. */
|
|
30
|
+
export async function conformance(instance, appSlug) {
|
|
31
|
+
const client = await personaClient(instance, PERSONAS.moderator);
|
|
32
|
+
const { data, error } = await client.rpc("conformance_check", {
|
|
33
|
+
app_slug: appSlug,
|
|
34
|
+
});
|
|
35
|
+
if (error)
|
|
36
|
+
throw new Error(error.message);
|
|
37
|
+
return (data ?? []);
|
|
38
|
+
}
|
|
39
|
+
/** The apps that have anything to check, for the picker. */
|
|
40
|
+
export async function listApps(instance) {
|
|
41
|
+
const { data, error } = await adminClient(instance)
|
|
42
|
+
.from("apps")
|
|
43
|
+
.select("slug")
|
|
44
|
+
.order("slug");
|
|
45
|
+
if (error)
|
|
46
|
+
throw new Error(error.message);
|
|
47
|
+
return (data ?? []).map((row) => row.slug);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Files a report against a profile, resolves it with quarantine, and checks
|
|
51
|
+
* that the freeze and the name reset both landed.
|
|
52
|
+
*
|
|
53
|
+
* The subject is a THROWAWAY member created for the run, not one of the seeded
|
|
54
|
+
* personas, and that is not squeamishness about touching fixtures.
|
|
55
|
+
* `file_report` corroborates an existing open report rather than filing a
|
|
56
|
+
* second one against the same content, and the seeds ship exactly such a report
|
|
57
|
+
* against the author persona's profile. Reusing it would make this function's
|
|
58
|
+
* first step silently become "add a corroboration", and every assertion after
|
|
59
|
+
* it would be reading the seed's decision instead of its own.
|
|
60
|
+
*
|
|
61
|
+
* Everything it creates, it deletes, including on the failure paths, which is
|
|
62
|
+
* why the teardown is in a `finally`. A contributor running this twice should
|
|
63
|
+
* get the same answer the second time.
|
|
64
|
+
*/
|
|
65
|
+
export async function quarantineRoundTrip(instance) {
|
|
66
|
+
const steps = [];
|
|
67
|
+
const admin = adminClient(instance);
|
|
68
|
+
const email = `roundtrip-${crypto.randomUUID().slice(0, 8)}@devdogs.test`;
|
|
69
|
+
const abusiveName = "BUY CHEAP FOLLOWERS NOW";
|
|
70
|
+
let subjectId = null;
|
|
71
|
+
let reportId = null;
|
|
72
|
+
try {
|
|
73
|
+
// ── Arrange: a member with an abusive display name and a name of record ──
|
|
74
|
+
//
|
|
75
|
+
// Through the GoTrue admin API rather than `from("users")`: the `auth`
|
|
76
|
+
// schema is not exposed over PostgREST, and should not be.
|
|
77
|
+
const { data: created, error: createErr } = await admin.auth.admin.createUser({
|
|
78
|
+
email,
|
|
79
|
+
password: PERSONA_PASSWORD,
|
|
80
|
+
email_confirm: true,
|
|
81
|
+
});
|
|
82
|
+
if (createErr ?? !created.user) {
|
|
83
|
+
throw new Error(`Could not create the subject account: ${createErr?.message}`);
|
|
84
|
+
}
|
|
85
|
+
subjectId = created.user.id;
|
|
86
|
+
const { error: profileErr } = await admin.from("profile").insert({
|
|
87
|
+
userId: subjectId,
|
|
88
|
+
preferredName: abusiveName,
|
|
89
|
+
bio: "Created by `pnpm devtools`, and deleted again immediately.",
|
|
90
|
+
// The name the reset restores. Written by the Involvement roster import
|
|
91
|
+
// in production (server/actions/verification.ts); set by hand here so the
|
|
92
|
+
// round-trip exercises the branch that has something to restore.
|
|
93
|
+
legalFirstName: "Round",
|
|
94
|
+
legalLastName: "Trip",
|
|
95
|
+
});
|
|
96
|
+
if (profileErr) {
|
|
97
|
+
throw new Error(`Could not create a profile: ${profileErr.message}`);
|
|
98
|
+
}
|
|
99
|
+
steps.push({
|
|
100
|
+
name: "fixture",
|
|
101
|
+
ok: true,
|
|
102
|
+
detail: `Created a profile named "${abusiveName}" with a name of record.`,
|
|
103
|
+
});
|
|
104
|
+
// ── Editable before ─────────────────────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// Establishes the baseline the freeze is measured against. Without it a
|
|
107
|
+
// profile that was never editable in the first place would pass the freeze
|
|
108
|
+
// check for the wrong reason.
|
|
109
|
+
const subject = await personaClient(instance, email);
|
|
110
|
+
await subject
|
|
111
|
+
.from("profile")
|
|
112
|
+
.update({ bio: "Edited." })
|
|
113
|
+
.eq("userId", subjectId);
|
|
114
|
+
const { data: beforeRow } = await admin
|
|
115
|
+
.from("profile")
|
|
116
|
+
.select("bio")
|
|
117
|
+
.eq("userId", subjectId)
|
|
118
|
+
.single();
|
|
119
|
+
const editableBefore = beforeRow?.bio === "Edited.";
|
|
120
|
+
steps.push({
|
|
121
|
+
name: "editable_before",
|
|
122
|
+
ok: editableBefore,
|
|
123
|
+
detail: editableBefore
|
|
124
|
+
? "The member can edit their own profile before it is reported."
|
|
125
|
+
: "The member could NOT edit their own profile before it was reported — the update policy is denying more than quarantine.",
|
|
126
|
+
});
|
|
127
|
+
// ── Act: file a report as the seeded member ─────────────────────────────
|
|
128
|
+
const memberClient = await personaClient(instance, PERSONAS.member);
|
|
129
|
+
// A literal, not a lookup. The vocabulary is a platform-owned enum, so
|
|
130
|
+
// there is no per-app row to find first. If this label ever stops existing,
|
|
131
|
+
// Postgres rejects the call by type rather than silently reporting "no
|
|
132
|
+
// reasons configured".
|
|
133
|
+
const { data: filed, error: fileErr } = await memberClient.rpc("file_report", {
|
|
134
|
+
app_slug: "platform",
|
|
135
|
+
content_type: "profile",
|
|
136
|
+
content_ref: subjectId,
|
|
137
|
+
reason: "spam",
|
|
138
|
+
description: "Filed by the devtools round-trip.",
|
|
139
|
+
});
|
|
140
|
+
if (fileErr)
|
|
141
|
+
throw new Error(`file_report failed: ${fileErr.message}`);
|
|
142
|
+
// A one-element array, not an object: these RPCs return `setof` now, so
|
|
143
|
+
// PostgREST serialises even a single result as a list.
|
|
144
|
+
const [row] = (filed ?? []);
|
|
145
|
+
if (!row)
|
|
146
|
+
throw new Error("file_report returned no rows");
|
|
147
|
+
reportId = row.reportId;
|
|
148
|
+
steps.push({
|
|
149
|
+
name: "file_report",
|
|
150
|
+
ok: true,
|
|
151
|
+
detail: `A member filed a report against the profile (${reportId}).`,
|
|
152
|
+
});
|
|
153
|
+
// ── Act: resolve with quarantine as the moderator ───────────────────────
|
|
154
|
+
const moderator = await personaClient(instance, PERSONAS.moderator);
|
|
155
|
+
const { error: resolveErr } = await moderator.rpc("resolve_report", {
|
|
156
|
+
report_id: reportId,
|
|
157
|
+
subject_action: "no_action",
|
|
158
|
+
filer_action: "no_action",
|
|
159
|
+
content_action: "quarantine",
|
|
160
|
+
moderator_note: "devtools round-trip",
|
|
161
|
+
});
|
|
162
|
+
if (resolveErr) {
|
|
163
|
+
throw new Error(`resolve_report failed: ${resolveErr.message}`);
|
|
164
|
+
}
|
|
165
|
+
steps.push({
|
|
166
|
+
name: "resolve_report",
|
|
167
|
+
ok: true,
|
|
168
|
+
detail: "A moderator resolved the report with `quarantine`.",
|
|
169
|
+
});
|
|
170
|
+
// ── Assert: the effect actually landed ──────────────────────────────────
|
|
171
|
+
const { data: after } = await admin
|
|
172
|
+
.from("profile")
|
|
173
|
+
.select("quarantinedBy, preferredName")
|
|
174
|
+
.eq("userId", subjectId)
|
|
175
|
+
.single();
|
|
176
|
+
const columnSet = Boolean(after?.quarantinedBy);
|
|
177
|
+
steps.push({
|
|
178
|
+
name: "quarantine_column",
|
|
179
|
+
ok: columnSet,
|
|
180
|
+
detail: columnSet
|
|
181
|
+
? 'The resolution set "quarantinedBy" on the row.'
|
|
182
|
+
: 'The resolution did NOT set "quarantinedBy" — apply_content_action did not reach this table.',
|
|
183
|
+
});
|
|
184
|
+
const nameReset = after?.preferredName === "Round Trip";
|
|
185
|
+
steps.push({
|
|
186
|
+
name: "name_reset",
|
|
187
|
+
ok: nameReset,
|
|
188
|
+
detail: nameReset
|
|
189
|
+
? 'The display name was reset to the name of record ("Round Trip").'
|
|
190
|
+
: `The display name is still "${String(after?.preferredName)}" — the reset trigger did not fire, so the remedy is a record with no effect.`,
|
|
191
|
+
});
|
|
192
|
+
// ── Assert: and that the member stopped being able to undo it ───────────
|
|
193
|
+
//
|
|
194
|
+
// The check that matters. A denied UPDATE under RLS is not an error, it
|
|
195
|
+
// simply matches no rows, so asserting on `error` here would pass just as
|
|
196
|
+
// happily if the write had landed. Read the value back instead.
|
|
197
|
+
await subject
|
|
198
|
+
.from("profile")
|
|
199
|
+
.update({ preferredName: abusiveName })
|
|
200
|
+
.eq("userId", subjectId);
|
|
201
|
+
const { data: frozenRow } = await admin
|
|
202
|
+
.from("profile")
|
|
203
|
+
.select("preferredName")
|
|
204
|
+
.eq("userId", subjectId)
|
|
205
|
+
.single();
|
|
206
|
+
const frozen = frozenRow?.preferredName !== abusiveName;
|
|
207
|
+
steps.push({
|
|
208
|
+
name: "frozen_after",
|
|
209
|
+
ok: frozen,
|
|
210
|
+
detail: frozen
|
|
211
|
+
? "The member can no longer change their display name back."
|
|
212
|
+
: 'The member CHANGED IT STRAIGHT BACK. The update policy does not filter on "quarantinedBy" — this is the check that matters.',
|
|
213
|
+
});
|
|
214
|
+
return steps;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
// Teardown runs whether or not the assertions held. Deleting the report
|
|
218
|
+
// first lets the resolution cascade, which releases the FK on the profile;
|
|
219
|
+
// deleting the auth user then cascades to the profile row itself.
|
|
220
|
+
if (reportId)
|
|
221
|
+
await admin.from("reports").delete().eq("id", reportId);
|
|
222
|
+
if (subjectId)
|
|
223
|
+
await admin.auth.admin.deleteUser(subjectId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { EMAIL_FIXTURES } from "./fixtures.js";
|
|
2
|
+
export type EmailFormat = "html" | "text";
|
|
3
|
+
export declare const EMAIL_FORMATS: readonly ["html", "text"];
|
|
4
|
+
export type EmailName = keyof typeof EMAIL_FIXTURES;
|
|
5
|
+
export declare const EMAIL_NAMES: EmailName[];
|
|
6
|
+
export interface EmailOptions {
|
|
7
|
+
names: EmailName[];
|
|
8
|
+
formats: EmailFormat[];
|
|
9
|
+
out: string;
|
|
10
|
+
noOutput: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function parseEmailArgs(argv: readonly string[], cwd: string): EmailOptions | Error;
|
|
13
|
+
export declare function destination(out: string, name: EmailName, format: EmailFormat): string;
|
|
14
|
+
export declare function generateEmails(options: EmailOptions): Promise<string[]>;
|
|
15
|
+
export declare function runEmails(argv: string[]): Promise<void>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** `pnpm devtools emails [template…] [--format html,text] [--out dir]`. */
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { isTTY, log, multiselect, note, text as askText } from "@clack/prompts";
|
|
6
|
+
import { positionals } from "../args.js";
|
|
7
|
+
import { errorMessage, explain, unwrap } from "../ui.js";
|
|
8
|
+
import { EMAIL_FIXTURES } from "./fixtures.js";
|
|
9
|
+
import { loadEmail } from "../repo/source.js";
|
|
10
|
+
export const EMAIL_FORMATS = ["html", "text"];
|
|
11
|
+
export const EMAIL_NAMES = Object.keys(EMAIL_FIXTURES);
|
|
12
|
+
function flagValue(argv, flag) {
|
|
13
|
+
const index = argv.indexOf(flag);
|
|
14
|
+
if (index === -1)
|
|
15
|
+
return undefined;
|
|
16
|
+
const value = argv[index + 1];
|
|
17
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
18
|
+
}
|
|
19
|
+
function expandHome(value) {
|
|
20
|
+
return value === "~" || value.startsWith("~/")
|
|
21
|
+
? resolve(homedir(), value.slice(2))
|
|
22
|
+
: value;
|
|
23
|
+
}
|
|
24
|
+
export function parseEmailArgs(argv, cwd) {
|
|
25
|
+
const requested = positionals(argv);
|
|
26
|
+
const unknown = requested.filter((name) => name !== "*" && !EMAIL_NAMES.includes(name));
|
|
27
|
+
if (unknown.length) {
|
|
28
|
+
return new Error(`No email template called ${unknown.join(", ")}. Try ${EMAIL_NAMES.join(", ")}, or *.`);
|
|
29
|
+
}
|
|
30
|
+
const rawFormats = (flagValue(argv, "--format") ?? "html")
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((value) => value.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
const badFormats = rawFormats.filter((value) => !EMAIL_FORMATS.includes(value));
|
|
35
|
+
if (badFormats.length)
|
|
36
|
+
return new Error(`Unknown format ${badFormats.join(", ")}. Try html or text.`);
|
|
37
|
+
const names = requested.includes("*")
|
|
38
|
+
? [...EMAIL_NAMES]
|
|
39
|
+
: requested;
|
|
40
|
+
return {
|
|
41
|
+
names,
|
|
42
|
+
formats: [...new Set(rawFormats)],
|
|
43
|
+
out: resolve(cwd, expandHome(flagValue(argv, "--out") ?? "email-previews")),
|
|
44
|
+
noOutput: argv.includes("--dry-run") || argv.includes("--no-output"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async function interactive(options) {
|
|
48
|
+
if (options.names.length)
|
|
49
|
+
return options;
|
|
50
|
+
if (!isTTY(process.stdout)) {
|
|
51
|
+
throw new Error("No terminal to choose templates. Name one, or pass * for all.");
|
|
52
|
+
}
|
|
53
|
+
const names = unwrap(await multiselect({
|
|
54
|
+
message: "Which emails?",
|
|
55
|
+
options: EMAIL_NAMES.map((name) => ({ value: name, label: name })),
|
|
56
|
+
initialValues: [...EMAIL_NAMES],
|
|
57
|
+
required: true,
|
|
58
|
+
}));
|
|
59
|
+
const formats = unwrap(await multiselect({
|
|
60
|
+
message: "Which formats?",
|
|
61
|
+
options: [
|
|
62
|
+
{ value: "html", label: "HTML", hint: "open in a browser" },
|
|
63
|
+
{ value: "text", label: "Plain text", hint: "the email alternative" },
|
|
64
|
+
],
|
|
65
|
+
initialValues: ["html"],
|
|
66
|
+
required: true,
|
|
67
|
+
}));
|
|
68
|
+
const entered = unwrap(await askText({
|
|
69
|
+
message: "Where should these go?",
|
|
70
|
+
placeholder: "./email-previews",
|
|
71
|
+
defaultValue: "./email-previews",
|
|
72
|
+
})).trim();
|
|
73
|
+
return {
|
|
74
|
+
...options,
|
|
75
|
+
names,
|
|
76
|
+
formats,
|
|
77
|
+
out: resolve(process.cwd(), expandHome(entered || "email-previews")),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function destination(out, name, format) {
|
|
81
|
+
return resolve(out, `${name}.${format === "text" ? "txt" : "html"}`);
|
|
82
|
+
}
|
|
83
|
+
export async function generateEmails(options) {
|
|
84
|
+
// Dynamic so every unrelated devtools command can still run before the
|
|
85
|
+
// generated email package has been built on a fresh checkout.
|
|
86
|
+
const { render } = await loadEmail();
|
|
87
|
+
const written = [];
|
|
88
|
+
for (const name of options.names) {
|
|
89
|
+
const email = render(name, EMAIL_FIXTURES[name]);
|
|
90
|
+
for (const format of options.formats) {
|
|
91
|
+
const file = destination(options.out, name, format);
|
|
92
|
+
await mkdir(dirname(file), { recursive: true });
|
|
93
|
+
await writeFile(file, format === "html" ? email.html : email.text);
|
|
94
|
+
written.push(file);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return written;
|
|
98
|
+
}
|
|
99
|
+
export async function runEmails(argv) {
|
|
100
|
+
const parsed = parseEmailArgs(argv, process.cwd());
|
|
101
|
+
if (parsed instanceof Error) {
|
|
102
|
+
explain("Could not read that.", parsed.message, [
|
|
103
|
+
"pnpm devtools emails",
|
|
104
|
+
"pnpm devtools emails '*' --format html,text --out ~/emails",
|
|
105
|
+
]);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const options = await interactive(parsed);
|
|
111
|
+
if (options.noOutput) {
|
|
112
|
+
const { render } = await loadEmail();
|
|
113
|
+
note(options.names
|
|
114
|
+
.map((name) => `${name}\n ${render(name, EMAIL_FIXTURES[name]).subject}\n ${options.formats.map((f) => destination(options.out, name, f)).join("\n ")}`)
|
|
115
|
+
.join("\n"), `${options.names.length} email template${options.names.length === 1 ? "" : "s"}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const written = await generateEmails(options);
|
|
119
|
+
for (const file of written)
|
|
120
|
+
log.success(file);
|
|
121
|
+
log.info(`${written.length} preview file${written.length === 1 ? "" : "s"} written.`);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
explain("Could not render those emails.", errorMessage(err), [
|
|
125
|
+
"Build the email package with `pnpm --filter @devdogsuga/email build`.",
|
|
126
|
+
"Then try `pnpm devtools emails '*' --out ~/emails`.",
|
|
127
|
+
]);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-only stand-in for `@devdogsuga/email`'s generated `Templates` map.
|
|
3
|
+
*
|
|
4
|
+
* `@devdogsuga/email` is `"private": true` and never published — it stays
|
|
5
|
+
* in DevDogsUGA and devtools resolves it FROM the repo at runtime
|
|
6
|
+
* (`repo/source.ts`'s `loadEmail()`). Backstage has no copy to pull the
|
|
7
|
+
* real generated type from, so `fixtures.ts`'s `satisfies` check is
|
|
8
|
+
* loosened to "every template's fixture is a flat string-prop object"
|
|
9
|
+
* instead of the real per-template exact prop shape. A prop typo here is
|
|
10
|
+
* caught at runtime (the real `render()` throws on a missing required
|
|
11
|
+
* prop), not at Backstage's typecheck time.
|
|
12
|
+
*/
|
|
13
|
+
export type EmailTemplates = Record<string, Record<string, string>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credible, non-sensitive values for local previews.
|
|
3
|
+
*
|
|
4
|
+
* `satisfies` is the maintenance mechanism: a new template or prop cannot be
|
|
5
|
+
* shipped without deciding what its preview should say.
|
|
6
|
+
*/
|
|
7
|
+
export declare const EMAIL_FIXTURES: {
|
|
8
|
+
JoinRequest: {
|
|
9
|
+
leadName: string;
|
|
10
|
+
applicantName: string;
|
|
11
|
+
teamName: string;
|
|
12
|
+
reviewUrl: string;
|
|
13
|
+
};
|
|
14
|
+
TeamInvite: {
|
|
15
|
+
inviteeName: string;
|
|
16
|
+
teamName: string;
|
|
17
|
+
leadName: string;
|
|
18
|
+
acceptUrl: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credible, non-sensitive values for local previews.
|
|
3
|
+
*
|
|
4
|
+
* `satisfies` is the maintenance mechanism: a new template or prop cannot be
|
|
5
|
+
* shipped without deciding what its preview should say.
|
|
6
|
+
*/
|
|
7
|
+
export const EMAIL_FIXTURES = {
|
|
8
|
+
JoinRequest: {
|
|
9
|
+
leadName: "Jordan",
|
|
10
|
+
applicantName: "Avery",
|
|
11
|
+
teamName: "Byte Bulldogs",
|
|
12
|
+
reviewUrl: "https://devdogsuga.org/teams/requests",
|
|
13
|
+
},
|
|
14
|
+
TeamInvite: {
|
|
15
|
+
inviteeName: "Avery",
|
|
16
|
+
teamName: "Byte Bulldogs",
|
|
17
|
+
leadName: "Jordan",
|
|
18
|
+
acceptUrl: "https://devdogsuga.org/teams/requests",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Four-way drift detection: local `.env`, Bitwarden, GitHub, Cloudflare.
|
|
3
|
+
*
|
|
4
|
+
* Each store answers a different question, and only one of them answers it
|
|
5
|
+
* completely:
|
|
6
|
+
*
|
|
7
|
+
* | Store | Exposes | So it can be checked for |
|
|
8
|
+
* |--------------------|-------------------|--------------------------|
|
|
9
|
+
* | local .env | names AND values | value drift |
|
|
10
|
+
* | Bitwarden | names AND values | value drift (the truth) |
|
|
11
|
+
* | GitHub *secrets* | names + updatedAt | presence, staleness |
|
|
12
|
+
* | GitHub *variables* | names AND values | value drift |
|
|
13
|
+
* | Cloudflare | names only | presence, orphans |
|
|
14
|
+
*
|
|
15
|
+
* That asymmetry is the whole design. A GitHub *secret* is write-only, so a
|
|
16
|
+
* clean audit of one does not mean "the values match". It means "nothing
|
|
17
|
+
* detectable is wrong", and the report says which is which rather than implying
|
|
18
|
+
* the stronger claim.
|
|
19
|
+
*
|
|
20
|
+
* Timestamps are what rescue that row from being presence-only. Bitwarden
|
|
21
|
+
* reports a `revisionDate` per secret and `gh secret list` reports `updatedAt`,
|
|
22
|
+
* so "was GitHub updated after Bitwarden last changed?" IS answerable. That is
|
|
23
|
+
* the failure this design actually has: a credential rotated in Bitwarden and
|
|
24
|
+
* never propagated, which a name-only check calls healthy right up until the old
|
|
25
|
+
* one is revoked.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ GitHub *variables* are the one downstream store that needs none of that
|
|
28
|
+
* reasoning: `gh variable list` returns the value, so the public per-environment
|
|
29
|
+
* keys are compared exactly the way the local file is. Staleness is deliberately
|
|
30
|
+
* NOT applied to them: a timestamp is a proxy for a comparison that could not
|
|
31
|
+
* be made, and using the proxy where the real answer is available would report a
|
|
32
|
+
* variable re-pushed with an identical value as drift. The security plan's §3.6
|
|
33
|
+
* limitation ("names only… a changed value is undetectable") now holds for the
|
|
34
|
+
* secret row alone.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ One scope sits outside that table altogether: the repository's OWN
|
|
37
|
+
* variables. Push writes environment-scoped values only, and an environment
|
|
38
|
+
* variable SHADOWS a repository one of the same name, so a repository-level
|
|
39
|
+
* copy of a managed key is read by nothing, drifts forever, and becomes the
|
|
40
|
+
* live value on the day somebody deletes the environment copy. It is checked
|
|
41
|
+
* here for the same reason §3.6's orphans are: nothing manages it, so nothing
|
|
42
|
+
* else in this system would ever mention it.
|
|
43
|
+
*/
|
|
44
|
+
export type Severity = "error" | "warning" | "info";
|
|
45
|
+
export interface Finding {
|
|
46
|
+
key: string;
|
|
47
|
+
severity: Severity;
|
|
48
|
+
/** Where the problem is, for grouping. */
|
|
49
|
+
store: "local" | "github" | "cloudflare";
|
|
50
|
+
summary: string;
|
|
51
|
+
}
|
|
52
|
+
export interface BwsEntry {
|
|
53
|
+
value: string;
|
|
54
|
+
/** ISO 8601. Absent when the CLI did not report one. */
|
|
55
|
+
revisionDate?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface GithubEntry {
|
|
58
|
+
/** Which GitHub environment this copy sits in. */
|
|
59
|
+
environment: string;
|
|
60
|
+
name: string;
|
|
61
|
+
/** ISO 8601. */
|
|
62
|
+
updatedAt?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A GitHub environment *variable*: the same shape plus the thing that makes
|
|
66
|
+
* variables worth having, a readable value.
|
|
67
|
+
*/
|
|
68
|
+
export interface GithubVariableEntry extends GithubEntry {
|
|
69
|
+
value: string;
|
|
70
|
+
}
|
|
71
|
+
export interface AuditInput {
|
|
72
|
+
/** Active assignments in the local `.env`. */
|
|
73
|
+
local: Map<string, string>;
|
|
74
|
+
/** Keys present in the local `.env` but commented out. */
|
|
75
|
+
localCommented?: Set<string>;
|
|
76
|
+
/** The source of truth. */
|
|
77
|
+
bws: Map<string, BwsEntry>;
|
|
78
|
+
/**
|
|
79
|
+
* Every GitHub copy found, across every environment this project feeds.
|
|
80
|
+
*
|
|
81
|
+
* A list rather than a map keyed by name, because one Bitwarden project can
|
|
82
|
+
* feed two GitHub environments and the interesting case is a key appearing in
|
|
83
|
+
* BOTH, which a name-keyed map would quietly collapse to one.
|
|
84
|
+
*/
|
|
85
|
+
github: GithubEntry[];
|
|
86
|
+
/**
|
|
87
|
+
* Every GitHub environment *variable* found, across the same environments.
|
|
88
|
+
*
|
|
89
|
+
* Separate from `github` rather than merged with a discriminator, because
|
|
90
|
+
* GitHub genuinely allows one name to exist as both a secret and a variable
|
|
91
|
+
* in one environment and resolves the ambiguity by never telling you. See
|
|
92
|
+
* `deploy/write-env.ts`, which refuses that case outright. Two lists can
|
|
93
|
+
* represent it; one list keyed by name cannot, and would silently pick a
|
|
94
|
+
* winner.
|
|
95
|
+
*/
|
|
96
|
+
githubVariables?: GithubVariableEntry[];
|
|
97
|
+
/**
|
|
98
|
+
* Keys that belong in the variable store rather than the secret store, from
|
|
99
|
+
* `variableKeys()` in the registry.
|
|
100
|
+
*
|
|
101
|
+
* Drives two things at once: which store a key is compared against, and the
|
|
102
|
+
* pair of misplacement errors. Absent, every key is treated as a secret,
|
|
103
|
+
* which is the pre-variables behaviour.
|
|
104
|
+
*/
|
|
105
|
+
variables?: ReadonlySet<string>;
|
|
106
|
+
/**
|
|
107
|
+
* Where a key is supposed to live: the PRIMARY environment, the one whose
|
|
108
|
+
* absence is an error. `null` means "nowhere in this environment", which is
|
|
109
|
+
* ordinary rather than wrong.
|
|
110
|
+
*/
|
|
111
|
+
route: (key: string) => string | null;
|
|
112
|
+
/**
|
|
113
|
+
* Whether a copy found in some OTHER environment is legitimate.
|
|
114
|
+
*
|
|
115
|
+
* Separate from `route` because the two questions stopped having the same
|
|
116
|
+
* answer when `production-apply` became a superset of `production`: an
|
|
117
|
+
* ordinary production key is now pushed to both, so "not where `route` says"
|
|
118
|
+
* no longer means "misplaced". Folding them back together reports every
|
|
119
|
+
* correctly-pushed copy as a stray to delete, and a reviewer who deletes 46
|
|
120
|
+
* of those learns to skim the stray finding, which is the one finding that
|
|
121
|
+
* catches an apply-tier credential sitting in the unreviewed environment.
|
|
122
|
+
*
|
|
123
|
+
* Defaults to the old behaviour (`only where route says`), so a caller that
|
|
124
|
+
* has not thought about fan-out gets the strict answer rather than a
|
|
125
|
+
* permissive one.
|
|
126
|
+
*/
|
|
127
|
+
accepted?: (key: string, environment: string) => boolean;
|
|
128
|
+
/** Worker name → secret names on it. Values are unreadable. */
|
|
129
|
+
cloudflare?: Map<string, Set<string>>;
|
|
130
|
+
/** Keys that legitimately live outside Bitwarden: the non-secrets. */
|
|
131
|
+
ignore?: ReadonlySet<string>;
|
|
132
|
+
/**
|
|
133
|
+
* Credentials that must never be stored remotely at all.
|
|
134
|
+
*
|
|
135
|
+
* Distinct from `ignore`, and the opposite of it: an ignored key is expected
|
|
136
|
+
* somewhere else, whereas one of these appearing in ANY remote store is a
|
|
137
|
+
* finding. Being in the local `.env` and nowhere else is the correct state,
|
|
138
|
+
* so the ordinary "not in Bitwarden" warning must not fire for them.
|
|
139
|
+
*/
|
|
140
|
+
neverStore?: ReadonlySet<string>;
|
|
141
|
+
/**
|
|
142
|
+
* Credentials signed at deploy time, whose only copy is on the deploy target.
|
|
143
|
+
*
|
|
144
|
+
* The third category, and it sits between the other two rather than beside
|
|
145
|
+
* them: unlike `ignore` it is a secret, and unlike `neverStore` there is one
|
|
146
|
+
* remote store it BELONGS in. A key like this is signed at deploy time and
|
|
147
|
+
* written straight to the Worker, so being on Cloudflare and nowhere else is
|
|
148
|
+
* exactly right. `SANDBOX_PROXY_TOKEN` is still declared this way in
|
|
149
|
+
* `apps/sandbox/env.ts` — nothing mints it any more, since the deploy that
|
|
150
|
+
* did was removed with the sandbox integration it authenticated, but the
|
|
151
|
+
* declaration is what keeps this category from reading "on production-
|
|
152
|
+
* sandbox, not in Bitwarden" the day someone does fill it in by hand.
|
|
153
|
+
*
|
|
154
|
+
* Without this set the Cloudflare pass below reports a key like that as an
|
|
155
|
+
* orphan, which the plan doc's §3.6 prune path then offers to delete.
|
|
156
|
+
* Marking it the other obvious way, as `never-store`, inverts the error into
|
|
157
|
+
* "must NEVER be a Worker secret" and is just as wrong in the opposite
|
|
158
|
+
* direction.
|
|
159
|
+
*/
|
|
160
|
+
minted?: ReadonlySet<string>;
|
|
161
|
+
/**
|
|
162
|
+
* Every key the registry knows. When provided, a local key outside it is
|
|
163
|
+
* reported as UNDECLARED: its own category, not folded into drift, because
|
|
164
|
+
* the fix is different: drift wants a push or a pull, an undeclared key
|
|
165
|
+
* wants a `define()` in the owning manifest (or the line removed). It is
|
|
166
|
+
* also why `env push` skipped it, and the audit should say so rather
|
|
167
|
+
* than let "not in Bitwarden" imply pushing would help.
|
|
168
|
+
*/
|
|
169
|
+
declared?: ReadonlySet<string>;
|
|
170
|
+
/**
|
|
171
|
+
* The repository's OWN variables, the scope with no environment, or the fact
|
|
172
|
+
* that they could not be listed.
|
|
173
|
+
*
|
|
174
|
+
* A union rather than a list, because three states have to stay apart and a
|
|
175
|
+
* list can only hold two of them: nobody looked (absent: the pre-existing
|
|
176
|
+
* behaviour, no claim either way), looked and saw these names
|
|
177
|
+
* (`readable`), and TRIED TO LOOK AND COULD NOT (`readable: false`). Listing
|
|
178
|
+
* repository variables can fail where listing environment ones succeeds, so
|
|
179
|
+
* the third state is ordinary rather than exotic, and collapsing it into an
|
|
180
|
+
* empty list is precisely the bug this check was added to catch, one scope
|
|
181
|
+
* up: a report that says "checked, nothing found" about a check that never
|
|
182
|
+
* ran.
|
|
183
|
+
*
|
|
184
|
+
* Names only. See `GhRepositoryVariable` for why the value is deliberately
|
|
185
|
+
* not fetched.
|
|
186
|
+
*/
|
|
187
|
+
repositoryVariables?: RepositoryVariableScan;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* What `gh variable list` (no `--env`) returned, or why it returned nothing.
|
|
191
|
+
*
|
|
192
|
+
* `reason` is carried rather than dropped because the two failures need
|
|
193
|
+
* different next steps and look identical from a finding that omits it: a
|
|
194
|
+
* missing permission is fixed by an admin, an unauthenticated CLI by the person
|
|
195
|
+
* reading the report.
|
|
196
|
+
*/
|
|
197
|
+
export type RepositoryVariableScan = {
|
|
198
|
+
readable: true;
|
|
199
|
+
names: readonly string[];
|
|
200
|
+
} | {
|
|
201
|
+
readable: false;
|
|
202
|
+
reason: string;
|
|
203
|
+
};
|
|
204
|
+
export declare function audit(input: AuditInput): Finding[];
|
|
205
|
+
export declare function hasErrors(findings: Finding[]): boolean;
|
|
206
|
+
export declare function renderFindings(findings: Finding[]): string;
|