@pithy-sh/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.
- package/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
7
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
8
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
9
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
11
|
+
import { DEFAULT_ENVIRONMENTS } from "@pithy-sh/core/src/naming/environment";
|
|
12
|
+
import { suppressionDatabaseName } from "@pithy-sh/email/src/provision/provisionEmail";
|
|
13
|
+
import type { EmailMessageLayers } from "@pithy-sh/email/src/templates/messages";
|
|
14
|
+
import { type ManagedEnvironment, managedEnvironments } from "@pithy-sh/secrets/src/scope";
|
|
15
|
+
import { defineCommand } from "citty";
|
|
16
|
+
import { parse } from "comment-json";
|
|
17
|
+
import { createProjectCliAudit } from "../audit/cliAudit";
|
|
18
|
+
import { classifyCapabilityLoadFailure } from "../capabilities/loadFailure";
|
|
19
|
+
import { loadTesters } from "../capabilities/testersLoader";
|
|
20
|
+
import { CloudflareTestersProvisioner, loadTestersProvisioning } from "../capabilities/testersProvisioner";
|
|
21
|
+
import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
22
|
+
import { cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
|
|
23
|
+
import { applyAppBindings, appWorkflowBindings } from "../project/appBindings";
|
|
24
|
+
import { loadProject, loadProjectEnvironments, projectCloudflareAccount, requireProjectName } from "../project/config";
|
|
25
|
+
import { ENV_ARG, requireEnvironment, requireManagedEnvironment } from "../project/environment";
|
|
26
|
+
import { composedProjectCapabilities, projectCapabilities, resolveWorkers } from "../project/workerScope";
|
|
27
|
+
import { openSeedDriver } from "../seed/drivers";
|
|
28
|
+
import { createCliLogger } from "../terminal/logger";
|
|
29
|
+
import { formatDone, formatJsonLine, formatList, withErrorReporting } from "../terminal/output";
|
|
30
|
+
import { dim, saffron } from "../terminal/style";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The sending identity the deployed daily-pass host is stamped with. Referenced by type only, the way
|
|
34
|
+
* `capabilities/testersProvisioner.ts` does, so the CLI gains no dependency on the optional package.
|
|
35
|
+
*/
|
|
36
|
+
type TestersEmailIdentity = import("@pithy-sh/testers/src/provision/resolveTestersConfig").TestersEmailIdentity;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `pithy testers` — run a closed test from the terminal.
|
|
40
|
+
*
|
|
41
|
+
* **The dashboard is the paid tier; this is not.** A developer with no dashboard has to be able to
|
|
42
|
+
* create a cohort, invite people, see where they stand, and chase whoever needs chasing, or the
|
|
43
|
+
* capability is a library that only works if you buy something. So every operation the control-plane
|
|
44
|
+
* routes expose has a command here, each non-interactive and each `--json`.
|
|
45
|
+
*
|
|
46
|
+
* **These read and write the database directly rather than calling the Worker over HTTP.** The routes
|
|
47
|
+
* exist for a management client that holds a control-plane credential; a developer at a terminal in
|
|
48
|
+
* their own repo already has the database, and making them mint a token to read their own roster would
|
|
49
|
+
* be ceremony with no security benefit. `dev` resolves through Miniflare against the same
|
|
50
|
+
* `.wrangler/state` `pithy dev` uses; every other environment resolves over the REST client keyed by
|
|
51
|
+
* that environment's `wrangler.jsonc` ids — the same seam `pithy seed` runs on.
|
|
52
|
+
*
|
|
53
|
+
* Every figure these print about opt-ins is Pithy's estimate from its own invite records. Google's
|
|
54
|
+
* count is authoritative and no API exposes it, and the closing line of every status output says so.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The sentence that closes every human-readable status output. */
|
|
58
|
+
const ESTIMATE_NOTE = [
|
|
59
|
+
"This is Pithy's estimate from your own invite records.",
|
|
60
|
+
"Google's count is authoritative and no API exposes it.",
|
|
61
|
+
].join("\n");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A required positional, or an actionable error.
|
|
65
|
+
*
|
|
66
|
+
* Citty types every positional as possibly absent, and a cast would turn a missing cohort name into
|
|
67
|
+
* `undefined` reaching a database query — which fails as a 404 for a cohort nobody named. Failing here
|
|
68
|
+
* says what is missing instead.
|
|
69
|
+
*/
|
|
70
|
+
function required(value: string | undefined, name: string, example: string): string {
|
|
71
|
+
if (value === undefined || value.trim() === "") {
|
|
72
|
+
throw new ValidationError({
|
|
73
|
+
message: `Missing ${name}.`,
|
|
74
|
+
action: `Pass it: ${example}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A flag that must be a whole positive number.
|
|
82
|
+
*
|
|
83
|
+
* `Number("twelve")` is `NaN`, and nothing downstream rejects it usefully: a `NaN` target size reaches
|
|
84
|
+
* `TestersCohort.encode` and surfaces as a raw `ZodError` from inside the data layer, and a `NaN` limit
|
|
85
|
+
* reaches Kysely's `.limit()` and then D1. Both name the schema field rather than the flag the user
|
|
86
|
+
* typed. Caught here, the answer names the flag.
|
|
87
|
+
*/
|
|
88
|
+
function wholeNumber(value: string | undefined, flag: string, fallback: number): number {
|
|
89
|
+
if (value === undefined) return fallback;
|
|
90
|
+
const parsed = Number(value);
|
|
91
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
92
|
+
throw new ValidationError({
|
|
93
|
+
message: `${flag} must be a whole number above zero. Got ${JSON.stringify(value)}.`,
|
|
94
|
+
action: `Pass a number: ${flag} ${fallback}`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A flag that must be one of a fixed set.
|
|
102
|
+
*
|
|
103
|
+
* The alternative is a cast, which types the value without checking it and moves the failure to
|
|
104
|
+
* whichever schema eventually parses it — `--platform windows` became a `ZodError` on `targetPlatform`
|
|
105
|
+
* raised inside `createCohort`, well away from the flag that caused it.
|
|
106
|
+
*/
|
|
107
|
+
function oneOf<const T extends readonly string[]>(
|
|
108
|
+
value: string | undefined,
|
|
109
|
+
allowed: T,
|
|
110
|
+
flag: string,
|
|
111
|
+
fallback: T[number],
|
|
112
|
+
): T[number] {
|
|
113
|
+
if (value === undefined) return fallback;
|
|
114
|
+
if (!allowed.includes(value)) {
|
|
115
|
+
throw new ValidationError({
|
|
116
|
+
message: `${flag} must be one of ${allowed.join(", ")}. Got ${JSON.stringify(value)}.`,
|
|
117
|
+
action: `Pass a supported value: ${flag} ${allowed[0]}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return value as T[number];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `--env` here is two different sets, deliberately. The roster subcommands take {@link ENV_ARG} and
|
|
125
|
+
* default to `dev`, because they talk to a local D1 and dev is where you use them. Provisioning deploys
|
|
126
|
+
* a Worker to a Cloudflare account, where there is no dev to deploy to, so it goes through
|
|
127
|
+
* {@link requireManagedEnvironment} instead. Both refuse an illegal environment as a `ValidationError`;
|
|
128
|
+
* neither lets a bare `ZodError` escape `withErrorReporting` with a stack trace.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A name within the deployment's configured ceiling.
|
|
133
|
+
*
|
|
134
|
+
* The HTTP schema has its own bound; this is the project's, which may be tighter, and the CLI had
|
|
135
|
+
* neither — `pithy testers create` passed whatever it was handed straight to an unbounded `text`
|
|
136
|
+
* column. `maxNameLength` was described as "the longest cohort or tester name accepted" and read by
|
|
137
|
+
* nothing on any path.
|
|
138
|
+
*/
|
|
139
|
+
function boundedName(value: string, limit: number, flag: string): string {
|
|
140
|
+
if (value.length > limit) {
|
|
141
|
+
throw new ValidationError({
|
|
142
|
+
message: `That ${flag} is longer than this deployment allows.`,
|
|
143
|
+
action: `Keep it under ${limit} characters.`,
|
|
144
|
+
detail: `${flag} was ${value.length}, limit ${limit}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The logger every read command hands to `readCohort`.
|
|
152
|
+
*
|
|
153
|
+
* Not optional in practice. When the activity read fails — auth resolvable but its tables absent, or a
|
|
154
|
+
* transient D1 error — the whole roster degrades to "never signed in", which reads exactly like a cohort
|
|
155
|
+
* nobody ever used. Without a logger that degradation reaches the terminal as a plausible answer with no
|
|
156
|
+
* trace of why. `createCliLogger` writes to `stderr` at `warn`, so the line shows without `--debug` and
|
|
157
|
+
* the `--json` contract on `stdout` is untouched.
|
|
158
|
+
*/
|
|
159
|
+
function readerLog(json: boolean): Logger {
|
|
160
|
+
return createCliLogger({ json }).child("testers");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Resolve the app D1 for an environment, plus the resolved testers config. */
|
|
164
|
+
async function openTesters(requested: string) {
|
|
165
|
+
// The roster commands' one door, so the `--env` check lives here rather than nine times over.
|
|
166
|
+
const env = requireEnvironment(requested);
|
|
167
|
+
const projectDir = process.cwd();
|
|
168
|
+
const workers = await resolveWorkers({ projectDir });
|
|
169
|
+
const { isTestersCapability } = await loadTesters();
|
|
170
|
+
const capability = projectCapabilities(workers).find(isTestersCapability);
|
|
171
|
+
if (!capability) {
|
|
172
|
+
throw new ValidationError({
|
|
173
|
+
message: "The testers capability is not configured.",
|
|
174
|
+
action: "Add `testers({ baseUrl: '...' })` to pithy.config.ts (run `pithy add testers`).",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// Capabilities live per Worker; the roster is one project-wide thing, so the first Worker composing
|
|
178
|
+
// testers supplies both the config and the database binding.
|
|
179
|
+
const worker = workers.find((entry) => entry.capabilities?.some(isTestersCapability)) ?? workers[0];
|
|
180
|
+
if (!worker) {
|
|
181
|
+
throw new ValidationError({
|
|
182
|
+
message: "No Worker composes the testers capability.",
|
|
183
|
+
action: "Run `pithy add testers --worker <name>` first.",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The account this project belongs to, before the driver resolves a credential. For any env but
|
|
188
|
+
// `dev` this driver is a REST client writing into a real D1 and a real R2, and `openSeedDriver`'s own
|
|
189
|
+
// doc comment calls this the guard against putting this project's rows in another company's tenant
|
|
190
|
+
// (#206). `buildProvisioner` below has named its account since then; this door had not.
|
|
191
|
+
const driver = await openSeedDriver({
|
|
192
|
+
workerDir: worker.dir,
|
|
193
|
+
persistRoot: projectDir,
|
|
194
|
+
env,
|
|
195
|
+
account: await projectCloudflareAccount(projectDir),
|
|
196
|
+
});
|
|
197
|
+
const modules = await loadTesters();
|
|
198
|
+
return {
|
|
199
|
+
driver,
|
|
200
|
+
d1: driver.d1("DB"),
|
|
201
|
+
db: modules.testersDatabase(driver.d1("DB")),
|
|
202
|
+
config: capability.testersConfig,
|
|
203
|
+
modules,
|
|
204
|
+
enqueue: await buildEnqueue(workers, driver.d1("DB")),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What a failed optional email import is classified *as*. The package half is what decides "ours or
|
|
210
|
+
* theirs"; the subpath is `detail` only.
|
|
211
|
+
*/
|
|
212
|
+
const EMAIL_MODULE = "@pithy-sh/email/src/capability";
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run an optional `@pithy-sh/email` import, answering `undefined` **only** for a package that is
|
|
216
|
+
* genuinely absent (#230).
|
|
217
|
+
*
|
|
218
|
+
* Email really is optional to `pithy testers`: a project that composes none still advances the roster,
|
|
219
|
+
* records the day, and writes its snapshot — it just sends nothing, and both call sites say so. So
|
|
220
|
+
* `undefined` is a real answer and not a swallowed error.
|
|
221
|
+
*
|
|
222
|
+
* But it is one of four answers this import can give, and the other three mean the package is right
|
|
223
|
+
* there: a dependency of its own that does not resolve, an export map that does not, and source that
|
|
224
|
+
* will not parse. Two bare `catch { … undefined }` blocks answered all four the same way, and the run
|
|
225
|
+
* then reported `sends: false` and printed *"no email capability is configured in this project"* about
|
|
226
|
+
* a capability the adopter had installed. Nothing in the output was a lie the adopter could catch: the
|
|
227
|
+
* one fact that would have told them apart was in the caught error, discarded a frame later.
|
|
228
|
+
*
|
|
229
|
+
* That is `docs/CONVENTIONS.md` §Refusals — **a `catch` reachable by more than one underlying failure
|
|
230
|
+
* may not name a single specific remedy** — and {@link classifyCapabilityLoadFailure} is the function
|
|
231
|
+
* #217 left for it. Exported so it is tested as a pure function against both runtimes' cause shapes,
|
|
232
|
+
* per the same convention: the `bin` runs on Bun, whose resolver errors are not `instanceof Error`.
|
|
233
|
+
*/
|
|
234
|
+
export async function loadOptionalEmail<T>(load: () => Promise<T> | T): Promise<T | undefined> {
|
|
235
|
+
try {
|
|
236
|
+
return await load();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const failure = classifyCapabilityLoadFailure("email", EMAIL_MODULE, error);
|
|
239
|
+
// The only absence there is. Everything else is a fault, and a fault that reads as an absence is
|
|
240
|
+
// the defect this exists to remove.
|
|
241
|
+
if (failure.kind === "not-installed") return undefined;
|
|
242
|
+
throw new ValidationError(
|
|
243
|
+
{ message: failure.message, action: failure.action, detail: failure.detail },
|
|
244
|
+
{ cause: error },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The email enqueue seam, built from the project's own email configuration.
|
|
251
|
+
*
|
|
252
|
+
* **The CLI needs no sending domain of its own.** `enqueueEmail` writes a row into `pithy_email_jobs`;
|
|
253
|
+
* the email worker that already exists for any project using auth is what actually delivers it. So the
|
|
254
|
+
* only thing needed here is the from-identity and the theme, and both are readable off the email
|
|
255
|
+
* capability in `pithy.config.ts` — no secret, no deployed worker, and it works against any environment.
|
|
256
|
+
*
|
|
257
|
+
* Returns `undefined` when email is not composed, so the pass still advances state and records the day
|
|
258
|
+
* rather than failing over a dependency this path can do without. **Not composed and not installed are
|
|
259
|
+
* the only two silences** — see {@link loadOptionalEmail} for why an installed-and-broken package is
|
|
260
|
+
* not a third.
|
|
261
|
+
*/
|
|
262
|
+
export async function buildEnqueue(workers: Awaited<ReturnType<typeof resolveWorkers>>, d1: D1Database) {
|
|
263
|
+
const modules = await loadOptionalEmail(async () => ({
|
|
264
|
+
...(await import("@pithy-sh/email/src/capability")),
|
|
265
|
+
...(await import("@pithy-sh/email/src/send/enqueue")),
|
|
266
|
+
...(await import("@pithy-sh/email/src/data/tables")),
|
|
267
|
+
}));
|
|
268
|
+
if (!modules) return undefined;
|
|
269
|
+
const { isEmailCapability, enqueueEmail, emailDatabase } = modules;
|
|
270
|
+
|
|
271
|
+
// **Assembled, not merely collected.** `layersFor` below is a value an i18n capability fills in its
|
|
272
|
+
// `compose` hook, and reading it off an unassembled capability is silent: the layers are all empty,
|
|
273
|
+
// so every kit `email/*` key renders as its own key string and this command mails a footer link
|
|
274
|
+
// labeled `email/shell.unsubscribe`. Worse than the English it replaced, because `enqueueEmail`
|
|
275
|
+
// falls back to the kit's own catalogs when it is handed no `layersFor` at all — so adding `i18n()`
|
|
276
|
+
// broke a path that was correct without it. See `capabilities/compose.ts`.
|
|
277
|
+
const capabilities = composedProjectCapabilities(workers);
|
|
278
|
+
|
|
279
|
+
const email = capabilities.find(isEmailCapability);
|
|
280
|
+
if (!email) return undefined;
|
|
281
|
+
|
|
282
|
+
const config = email.emailConfig;
|
|
283
|
+
/**
|
|
284
|
+
* The language a nudge goes out in: the project's default, and nothing narrower.
|
|
285
|
+
*
|
|
286
|
+
* This runs from a terminal. There is no request to negotiate from and no per-tester preference on a
|
|
287
|
+
* roster row, so the only truthful answer is the language the project itself declares — which is what
|
|
288
|
+
* the composed `i18n` capability's `defaultLocale` is. Duck-typed off the composed set rather than
|
|
289
|
+
* imported, because `@pithy-sh/i18n` is optional and the CLI must not hard-depend on a capability an
|
|
290
|
+
* adopter may never add; absent, the nudge is written in the kit's English exactly as before.
|
|
291
|
+
*
|
|
292
|
+
* It moves the shell only. A nudge's words are supplied per message by whoever asked for it, so this
|
|
293
|
+
* decides the document's `lang` and `dir` and the footer, not the letter.
|
|
294
|
+
*/
|
|
295
|
+
const i18n = capabilities.find(
|
|
296
|
+
(candidate): candidate is Capability & { layersFor: EmailMessageLayers; i18nConfig: { defaultLocale: string } } =>
|
|
297
|
+
candidate.name === "i18n" &&
|
|
298
|
+
typeof (candidate as { layersFor?: unknown }).layersFor === "function" &&
|
|
299
|
+
typeof (candidate as { i18nConfig?: { defaultLocale?: unknown } }).i18nConfig?.defaultLocale === "string",
|
|
300
|
+
);
|
|
301
|
+
return async (input: { to: string; template: string; payload: unknown }) =>
|
|
302
|
+
enqueueEmail(
|
|
303
|
+
{
|
|
304
|
+
db: emailDatabase(d1),
|
|
305
|
+
fromAddress: config.fromAddress,
|
|
306
|
+
fromName: config.fromName,
|
|
307
|
+
// Already resolved on the capability — the preset and any overrides were merged at assembly.
|
|
308
|
+
theme: config.theme,
|
|
309
|
+
...(i18n ? { layersFor: i18n.layersFor } : {}),
|
|
310
|
+
// No send-Workflow binding from a terminal, so the row stays `pending` and the email worker's
|
|
311
|
+
// every-minute scheduler picks it up. Slower by up to a minute, and it loses nothing.
|
|
312
|
+
sender: undefined,
|
|
313
|
+
now: new Date(),
|
|
314
|
+
newId: () => crypto.randomUUID(),
|
|
315
|
+
},
|
|
316
|
+
i18n ? { ...input, locale: i18n.i18nConfig.defaultLocale } : input,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Build the live provisioner for a project.
|
|
322
|
+
*
|
|
323
|
+
* The sending identity is read off the composed email capability rather than asked for: the adopter has
|
|
324
|
+
* already told `pithy.config.ts` which domain they send from, and asking twice is how the two drift.
|
|
325
|
+
*
|
|
326
|
+
* The project name comes from `requireProjectName`, never `resolveProjectName`: it leads the deployed
|
|
327
|
+
* host name and the suppression database this looks up, and both have to be the same names the other
|
|
328
|
+
* commands compute (docs/NAMING.md).
|
|
329
|
+
*/
|
|
330
|
+
async function buildProvisioner(projectDir: string) {
|
|
331
|
+
// The name first, before the credentials: both are local checks, and a config that cannot name the
|
|
332
|
+
// project is not a Cloudflare problem to report as one.
|
|
333
|
+
const config = await loadProject(projectDir);
|
|
334
|
+
const project = requireProjectName(config);
|
|
335
|
+
// The project's own environment set (#241), carried rather than assumed.
|
|
336
|
+
const environments = loadProjectEnvironments(config);
|
|
337
|
+
const selection = await projectCloudflareAccount(projectDir);
|
|
338
|
+
const vars = cloudflareEnv({ account: selection });
|
|
339
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
340
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
341
|
+
// What vouches for that id (#378). The host teardown reads a missing Worker as "already gone", and an
|
|
342
|
+
// account nothing claims answers exactly that way about a host that is still running.
|
|
343
|
+
const account: ConfirmedAccount = { accountId, confirmation: cloudflareAccountConfirmation({ account: selection }) };
|
|
344
|
+
if (!accountId || !apiToken) {
|
|
345
|
+
throw new ValidationError({
|
|
346
|
+
message: "Cloudflare credentials are missing.",
|
|
347
|
+
action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const workers = await resolveWorkers({ projectDir });
|
|
352
|
+
// Assembled, for the same reason the enqueue seam above is: the catalogs stamped into the deployed
|
|
353
|
+
// host come off `hostCatalogs()`, which answers `{}` until every `compose` hook has run.
|
|
354
|
+
const capabilities = composedProjectCapabilities(workers);
|
|
355
|
+
const { isTestersCapability } = await loadTesters();
|
|
356
|
+
const testers = capabilities.find(isTestersCapability);
|
|
357
|
+
if (!testers) {
|
|
358
|
+
throw new ValidationError({
|
|
359
|
+
message: "The testers capability is not configured.",
|
|
360
|
+
action: "Add `testers({ baseUrl: '...' })` to pithy.config.ts (run `pithy add testers`).",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Undefined when no email capability is composed. The pass then advances state and writes its
|
|
365
|
+
// snapshot but sends nothing — a real state, and better than deploying a host that mails from a
|
|
366
|
+
// domain the adopter's DKIM does not cover. Installed-and-broken is not that state, and refuses,
|
|
367
|
+
// through the same classifier as the enqueue seam: deploying a host that silently never mails is the
|
|
368
|
+
// other half of the same mistake.
|
|
369
|
+
const emailModule = await loadOptionalEmail(() => import("@pithy-sh/email/src/capability"));
|
|
370
|
+
const composed = emailModule && capabilities.find(emailModule.isEmailCapability);
|
|
371
|
+
const email: TestersEmailIdentity | undefined = composed
|
|
372
|
+
? {
|
|
373
|
+
fromAddress: composed.emailConfig.fromAddress,
|
|
374
|
+
fromName: composed.emailConfig.fromName,
|
|
375
|
+
theme: composed.emailConfig.theme,
|
|
376
|
+
// The same journey the theme makes, and for the same reason: the daily-pass host composes
|
|
377
|
+
// nothing, so the project's catalogs can only reach it as a var a provision run writes. Without
|
|
378
|
+
// it the host reads `env.EMAIL_MESSAGES` and finds a var nobody wrote — the defect this
|
|
379
|
+
// capability's own `enqueueSeam.ts` documents, surviving on the second Worker that reads it.
|
|
380
|
+
messages: composed.hostCatalogs(),
|
|
381
|
+
}
|
|
382
|
+
: undefined;
|
|
383
|
+
|
|
384
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
385
|
+
return {
|
|
386
|
+
email,
|
|
387
|
+
project,
|
|
388
|
+
environments,
|
|
389
|
+
provisioner: new CloudflareTestersProvisioner({
|
|
390
|
+
cf,
|
|
391
|
+
project,
|
|
392
|
+
account,
|
|
393
|
+
apiToken,
|
|
394
|
+
testersConfig: testers.testersConfig,
|
|
395
|
+
email,
|
|
396
|
+
resolveEnv: buildResolveEnv(projectDir, project, cf, account),
|
|
397
|
+
audit: await buildAudit(projectDir, accountId, apiToken),
|
|
398
|
+
}),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The audit emitter for a testers command. A no-op without credentials or the audit capability. */
|
|
403
|
+
async function buildAudit(projectDir: string, accountId: string, apiToken: string) {
|
|
404
|
+
// `env` selects the audit database only, and defaults to `dev`: this command spans environments, so no
|
|
405
|
+
// single value is true for the run; each event states the environment it acted on.
|
|
406
|
+
return createProjectCliAudit({ projectDir, accountId, apiToken });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A wrangler env stanza — only the fields the host deploy reads from the project's own config. */
|
|
410
|
+
interface WranglerStanza {
|
|
411
|
+
d1_databases?: { binding: string; database_id?: string }[];
|
|
412
|
+
env?: Record<string, WranglerStanza | undefined>;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Resolve the per-environment database ids the host binds, from the project's `wrangler.jsonc`. */
|
|
416
|
+
function buildResolveEnv(projectDir: string, project: string, cf: CloudflareClients, account: ConfirmedAccount) {
|
|
417
|
+
return async (env: ManagedEnvironment) => {
|
|
418
|
+
const config = parse(await readFile(join(projectDir, "wrangler.jsonc"), "utf8")) as unknown as WranglerStanza;
|
|
419
|
+
const stanza = config.env?.[env];
|
|
420
|
+
if (!stanza) {
|
|
421
|
+
throw new ValidationError({
|
|
422
|
+
message: `wrangler.jsonc has no env.${env} stanza.`,
|
|
423
|
+
action: `Add the ${env} environment to wrangler.jsonc with its DB binding.`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const appDatabaseId = stanza.d1_databases?.find((database) => database.binding === "DB")?.database_id;
|
|
427
|
+
if (!appDatabaseId) {
|
|
428
|
+
throw new ValidationError({
|
|
429
|
+
message: `wrangler.jsonc env.${env} has no DB database_id.`,
|
|
430
|
+
action: `Provision the ${env} app database and set its id on the DB binding — the roster lives there.`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// One suppression database per project, shared across that project's environments, matching how
|
|
435
|
+
// `@pithy-sh/email` provisions it: an unsubscribe in production has to stop staging too. So it is
|
|
436
|
+
// looked up by name rather than read from the env stanza.
|
|
437
|
+
const suppressionName = suppressionDatabaseName(project);
|
|
438
|
+
const suppression = await findOnConfirmedAccount({
|
|
439
|
+
...account,
|
|
440
|
+
what: `the ${suppressionName} database`,
|
|
441
|
+
find: () => cf.d1Provisioner().findDatabaseByName(suppressionName),
|
|
442
|
+
});
|
|
443
|
+
if (!suppression) {
|
|
444
|
+
throw new ValidationError({
|
|
445
|
+
message: `This project's email-suppression database (${suppressionName}) does not exist.`,
|
|
446
|
+
action: "Run `pithy email provision` first — the daily pass reads it to reconcile bounced addresses.",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return { appDatabaseId, suppressionDatabaseId: suppression.uuid };
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const provision = defineCommand({
|
|
454
|
+
meta: { name: "provision", description: "Deploy the daily-pass Workflow worker and write its binding" },
|
|
455
|
+
args: {
|
|
456
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
457
|
+
env: {
|
|
458
|
+
type: "string",
|
|
459
|
+
description: `Provision one environment only, from the set pithy.config.ts declares (default ${DEFAULT_ENVIRONMENTS.join(", ")}). Omit for every one.`,
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
run: ({ args }) =>
|
|
463
|
+
withErrorReporting(args.json, async () => {
|
|
464
|
+
const projectDir = process.cwd();
|
|
465
|
+
const { provisioner, project, email, environments: declared } = await buildProvisioner(projectDir);
|
|
466
|
+
const { testersWorkflowRegistry, TESTERS_CAPABILITY, provisionTesters } = await loadTestersProvisioning();
|
|
467
|
+
|
|
468
|
+
// Parsed, not cast. `--env dev` is a real thing to type, and dev is local-only — the cast turned a
|
|
469
|
+
// one-line answer into a raw Cloudflare error from a worker that was never deployed.
|
|
470
|
+
const environments: ManagedEnvironment[] = args.env
|
|
471
|
+
? [requireManagedEnvironment(args.env, declared)]
|
|
472
|
+
: managedEnvironments(declared);
|
|
473
|
+
|
|
474
|
+
const results = await provisionTesters(provisioner, project, environments);
|
|
475
|
+
|
|
476
|
+
for (const { env } of results) {
|
|
477
|
+
// Only now can the Workflow binding be written. `pithy add testers` cannot: wrangler requires a
|
|
478
|
+
// `name` and a `class_name` on every `workflows` entry, and the deployed name is per environment
|
|
479
|
+
// (`<project>-<env>-testers-daily`). An entry short of either field fails the whole config, so `add`
|
|
480
|
+
// emits none and this completes it — see capabilities/add.ts.
|
|
481
|
+
await applyAppBindings(projectDir, env, {
|
|
482
|
+
workflows: appWorkflowBindings(testersWorkflowRegistry, { project, capability: TESTERS_CAPABILITY, env }),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (args.json) {
|
|
487
|
+
process.stdout.write(
|
|
488
|
+
`${formatJsonLine({ command: "testers provision", results, sends: email !== undefined })}\n`,
|
|
489
|
+
);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
for (const { env, worker } of results) {
|
|
493
|
+
process.stdout.write(`${env}: ${worker} deployed, TESTERS_DAILY bound.\n`);
|
|
494
|
+
}
|
|
495
|
+
process.stdout.write(dim("The pass runs daily at 05:00 UTC. `pithy testers run` runs one now.\n"));
|
|
496
|
+
if (!email) {
|
|
497
|
+
// Said here rather than discovered on the first silent morning.
|
|
498
|
+
process.stdout.write(
|
|
499
|
+
dim("No email capability is composed, so the pass will record the day but send nothing.\n"),
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
503
|
+
}),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const deprovision = defineCommand({
|
|
507
|
+
meta: { name: "deprovision", description: "Delete the daily-pass worker. The roster and its history stay." },
|
|
508
|
+
args: {
|
|
509
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
510
|
+
env: {
|
|
511
|
+
type: "string",
|
|
512
|
+
description: `Deprovision one environment only, from the set pithy.config.ts declares (default ${DEFAULT_ENVIRONMENTS.join(", ")}). Omit for every one.`,
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
run: ({ args }) =>
|
|
516
|
+
withErrorReporting(args.json, async () => {
|
|
517
|
+
const projectDir = process.cwd();
|
|
518
|
+
const { provisioner, project, environments: declared } = await buildProvisioner(projectDir);
|
|
519
|
+
const { deprovisionTesters } = await loadTestersProvisioning();
|
|
520
|
+
|
|
521
|
+
const environments: ManagedEnvironment[] = args.env
|
|
522
|
+
? [requireManagedEnvironment(args.env, declared)]
|
|
523
|
+
: managedEnvironments(declared);
|
|
524
|
+
const results = await deprovisionTesters(provisioner, project, environments);
|
|
525
|
+
|
|
526
|
+
if (args.json) {
|
|
527
|
+
process.stdout.write(`${formatJsonLine({ command: "testers deprovision", results })}\n`);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
for (const { env, worker } of results) process.stdout.write(`${env}: ${worker} deleted.\n`);
|
|
531
|
+
// Deliberately non-destructive to data. The cohorts, the roster, and the whole snapshot series are
|
|
532
|
+
// rows in the adopter's own D1 and are not this command's to remove.
|
|
533
|
+
process.stdout.write(dim("Your cohorts, roster, and trend history are untouched.\n"));
|
|
534
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
535
|
+
}),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const create = defineCommand({
|
|
539
|
+
meta: { name: "create", description: "Create a testing cohort" },
|
|
540
|
+
args: {
|
|
541
|
+
name: { type: "positional", description: "A label for the cohort, e.g. closed-test" },
|
|
542
|
+
env: ENV_ARG,
|
|
543
|
+
"target-size": { type: "string", description: "Testers required simultaneously (default: 12, Play's floor)" },
|
|
544
|
+
"window-days": { type: "string", description: "Continuous days required (default: 14, Play's window)" },
|
|
545
|
+
"max-roster": { type: "string", description: "Roster cap (default: 100)" },
|
|
546
|
+
platform: { type: "string", description: "android or ios (default: android)" },
|
|
547
|
+
"store-url": {
|
|
548
|
+
type: "string",
|
|
549
|
+
description: "The store's own opt-in link, e.g. https://play.google.com/apps/testing/<package>",
|
|
550
|
+
},
|
|
551
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
552
|
+
},
|
|
553
|
+
run: ({ args }) =>
|
|
554
|
+
withErrorReporting(args.json, async () => {
|
|
555
|
+
const { db, config, driver, modules } = await openTesters(args.env);
|
|
556
|
+
try {
|
|
557
|
+
const defaults = config.cohortDefaults;
|
|
558
|
+
const cohort = await modules.createCohort(
|
|
559
|
+
{ db, now: new Date(), newId: () => crypto.randomUUID() },
|
|
560
|
+
{
|
|
561
|
+
name: boundedName(
|
|
562
|
+
required(args.name, "a cohort name", "pithy testers create closed-test"),
|
|
563
|
+
config.maxNameLength,
|
|
564
|
+
"cohort name",
|
|
565
|
+
),
|
|
566
|
+
targetSize: wholeNumber(args["target-size"], "--target-size", defaults.targetSize),
|
|
567
|
+
windowDays: wholeNumber(args["window-days"], "--window-days", defaults.windowDays),
|
|
568
|
+
maxRosterSize: wholeNumber(args["max-roster"], "--max-roster", defaults.maxRosterSize),
|
|
569
|
+
targetPlatform: oneOf(args.platform, ["android", "ios"] as const, "--platform", defaults.targetPlatform),
|
|
570
|
+
storeOptInUrl: args["store-url"] ?? defaults.storeOptInUrl,
|
|
571
|
+
resetPolicy: defaults.resetPolicy,
|
|
572
|
+
},
|
|
573
|
+
);
|
|
574
|
+
if (args.json) {
|
|
575
|
+
process.stdout.write(`${formatJsonLine({ command: "testers create", cohort })}\n`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
process.stdout.write(`${cohort.name}: ${cohort.targetSize} testers for ${cohort.windowDays} days.\n`);
|
|
579
|
+
// The number that actually matters, and the one nobody works out for themselves.
|
|
580
|
+
process.stdout.write(
|
|
581
|
+
dim(`Carry more than ${cohort.targetSize} — that is the number that must still be standing at the end.\n`),
|
|
582
|
+
);
|
|
583
|
+
// Said now rather than discovered later: without it the second email never goes out, and a
|
|
584
|
+
// tester who agreed to help waits forever for a link nobody sent.
|
|
585
|
+
if (!cohort.storeOptInUrl) {
|
|
586
|
+
process.stdout.write(
|
|
587
|
+
dim("No store link yet. Set one with --store-url before anyone can actually join the test.\n"),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
591
|
+
} finally {
|
|
592
|
+
await driver.dispose();
|
|
593
|
+
}
|
|
594
|
+
}),
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const list = defineCommand({
|
|
598
|
+
meta: { name: "list", description: "List cohorts and where each one stands" },
|
|
599
|
+
args: {
|
|
600
|
+
env: ENV_ARG,
|
|
601
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
602
|
+
},
|
|
603
|
+
run: ({ args }) =>
|
|
604
|
+
withErrorReporting(args.json, async () => {
|
|
605
|
+
const { db, d1, config, driver, modules } = await openTesters(args.env);
|
|
606
|
+
try {
|
|
607
|
+
const now = new Date();
|
|
608
|
+
const cohorts = await modules.listCohorts(db);
|
|
609
|
+
const rows = [];
|
|
610
|
+
for (const cohort of cohorts) {
|
|
611
|
+
const reading = await modules.readCohort(db, d1, cohort, config, now, readerLog(args.json));
|
|
612
|
+
rows.push({
|
|
613
|
+
id: cohort.id,
|
|
614
|
+
name: cohort.name,
|
|
615
|
+
estimatedOptedIn: reading.clock.estimatedOptedInCount,
|
|
616
|
+
targetSize: cohort.targetSize,
|
|
617
|
+
estimatedHeldDays: reading.clock.estimatedHeldDays,
|
|
618
|
+
windowDays: cohort.windowDays,
|
|
619
|
+
closed: cohort.closedAt !== null,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
if (args.json) {
|
|
623
|
+
process.stdout.write(`${formatJsonLine({ command: "testers list", cohorts: rows })}\n`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (rows.length === 0) {
|
|
627
|
+
process.stdout.write("Nothing here yet.\n");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
process.stdout.write(
|
|
631
|
+
`${formatList(
|
|
632
|
+
rows.map((row) => ({
|
|
633
|
+
name: row.name,
|
|
634
|
+
description: `${row.estimatedOptedIn} of ${row.targetSize} opted in. Day ${row.estimatedHeldDays} of ${row.windowDays}.`,
|
|
635
|
+
})),
|
|
636
|
+
)}\n`,
|
|
637
|
+
);
|
|
638
|
+
process.stdout.write(`\n${dim(ESTIMATE_NOTE)}\n`);
|
|
639
|
+
} finally {
|
|
640
|
+
await driver.dispose();
|
|
641
|
+
}
|
|
642
|
+
}),
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const invite = defineCommand({
|
|
646
|
+
meta: { name: "invite", description: "Add one or more addresses to a cohort's roster" },
|
|
647
|
+
args: {
|
|
648
|
+
cohort: { type: "positional", description: "The cohort name or id" },
|
|
649
|
+
email: { type: "string", description: "Address to invite. Repeat the flag for several.", required: true },
|
|
650
|
+
name: { type: "string", description: "A display name for the roster" },
|
|
651
|
+
env: ENV_ARG,
|
|
652
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
653
|
+
},
|
|
654
|
+
run: ({ args }) =>
|
|
655
|
+
withErrorReporting(args.json, async () => {
|
|
656
|
+
const { db, config, driver, modules } = await openTesters(args.env);
|
|
657
|
+
try {
|
|
658
|
+
const cohort = await modules.resolveCohortRef(
|
|
659
|
+
db,
|
|
660
|
+
required(args.cohort, "a cohort", "pithy testers status closed-test"),
|
|
661
|
+
);
|
|
662
|
+
const emails = (Array.isArray(args.email) ? args.email : [args.email]).map(String);
|
|
663
|
+
const invited = [];
|
|
664
|
+
for (const email of emails) {
|
|
665
|
+
const result = await modules.inviteMember(
|
|
666
|
+
{ db, now: new Date(), newId: () => crypto.randomUUID() },
|
|
667
|
+
{
|
|
668
|
+
cohortId: cohort.id,
|
|
669
|
+
email,
|
|
670
|
+
name: args.name ? boundedName(args.name, config.maxNameLength, "tester name") : null,
|
|
671
|
+
maxRosterSize: cohort.maxRosterSize,
|
|
672
|
+
},
|
|
673
|
+
);
|
|
674
|
+
invited.push({ id: result.member.id, email: result.member.email, created: result.created });
|
|
675
|
+
}
|
|
676
|
+
if (args.json) {
|
|
677
|
+
process.stdout.write(`${formatJsonLine({ command: "testers invite", cohort: cohort.id, invited })}\n`);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
process.stdout.write(`${invited.length} added to ${cohort.name}.\n`);
|
|
681
|
+
process.stdout.write(dim("Run `pithy testers run` to ask them whether they will test.\n"));
|
|
682
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
683
|
+
} finally {
|
|
684
|
+
await driver.dispose();
|
|
685
|
+
}
|
|
686
|
+
}),
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const pending = defineCommand({
|
|
690
|
+
meta: {
|
|
691
|
+
name: "pending",
|
|
692
|
+
description: "Who has agreed to test and is waiting to be added to the store's tester list",
|
|
693
|
+
},
|
|
694
|
+
args: {
|
|
695
|
+
cohort: { type: "positional", description: "The cohort name or id" },
|
|
696
|
+
env: ENV_ARG,
|
|
697
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
698
|
+
},
|
|
699
|
+
run: ({ args }) =>
|
|
700
|
+
withErrorReporting(args.json, async () => {
|
|
701
|
+
const { db, driver, modules } = await openTesters(args.env);
|
|
702
|
+
try {
|
|
703
|
+
const cohort = await modules.resolveCohortRef(
|
|
704
|
+
db,
|
|
705
|
+
required(args.cohort, "a cohort", "pithy testers pending closed-test"),
|
|
706
|
+
);
|
|
707
|
+
// The one step Pithy cannot do for you: the Play Developer API has no way to add an address to
|
|
708
|
+
// an email list, so this prints the addresses and you paste them into the console. Until that is
|
|
709
|
+
// done the store link answers `App not available`, which is why the second email waits.
|
|
710
|
+
const waiting = (await modules.listMembers(db, cohort.id)).filter((member) => member.state === "accepted");
|
|
711
|
+
if (args.json) {
|
|
712
|
+
process.stdout.write(
|
|
713
|
+
`${formatJsonLine({ command: "testers pending", cohort: cohort.id, emails: waiting.map((m) => m.email) })}\n`,
|
|
714
|
+
);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (waiting.length === 0) {
|
|
718
|
+
process.stdout.write("Nobody waiting.\n");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
for (const member of waiting) process.stdout.write(`${member.email}\n`);
|
|
722
|
+
process.stdout.write(
|
|
723
|
+
dim(`\nAdd these to the tester list in the console, then run \`pithy testers run\` to send them the link.\n`),
|
|
724
|
+
);
|
|
725
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
726
|
+
} finally {
|
|
727
|
+
await driver.dispose();
|
|
728
|
+
}
|
|
729
|
+
}),
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
const roster = defineCommand({
|
|
733
|
+
meta: { name: "roster", description: "The full roster, with per-tester activity and health" },
|
|
734
|
+
args: {
|
|
735
|
+
cohort: { type: "positional", description: "The cohort name or id" },
|
|
736
|
+
env: ENV_ARG,
|
|
737
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
738
|
+
},
|
|
739
|
+
run: ({ args }) =>
|
|
740
|
+
withErrorReporting(args.json, async () => {
|
|
741
|
+
const { db, d1, config, driver, modules } = await openTesters(args.env);
|
|
742
|
+
try {
|
|
743
|
+
const now = new Date();
|
|
744
|
+
const cohort = await modules.resolveCohortRef(
|
|
745
|
+
db,
|
|
746
|
+
required(args.cohort, "a cohort", "pithy testers status closed-test"),
|
|
747
|
+
);
|
|
748
|
+
const reading = await modules.readCohort(db, d1, cohort, config, now, readerLog(args.json));
|
|
749
|
+
const view = modules.toCohortView(
|
|
750
|
+
reading,
|
|
751
|
+
config,
|
|
752
|
+
{ includeMembers: true, snapshots: [], latest: undefined },
|
|
753
|
+
now,
|
|
754
|
+
);
|
|
755
|
+
if (args.json) {
|
|
756
|
+
process.stdout.write(`${formatJsonLine({ command: "testers roster", cohort: view })}\n`);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
process.stdout.write(
|
|
760
|
+
`${formatList(
|
|
761
|
+
(view.members ?? []).map((member) => ({
|
|
762
|
+
name: member.email,
|
|
763
|
+
description: describeMember(member),
|
|
764
|
+
})),
|
|
765
|
+
)}\n`,
|
|
766
|
+
);
|
|
767
|
+
process.stdout.write(`\n${dim(ESTIMATE_NOTE)}\n`);
|
|
768
|
+
} finally {
|
|
769
|
+
await driver.dispose();
|
|
770
|
+
}
|
|
771
|
+
}),
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
/** One roster line: the estimate, then the fact, kept visibly apart. */
|
|
775
|
+
function describeMember(member: {
|
|
776
|
+
state: string;
|
|
777
|
+
health: number | null;
|
|
778
|
+
activity: { state: string; daysDark: number | null };
|
|
779
|
+
}): string {
|
|
780
|
+
const estimated = member.state.replace(/_/g, " ");
|
|
781
|
+
if (member.activity.state === "never_linked") return `${estimated} · never signed in`;
|
|
782
|
+
if (member.activity.state === "unreachable") return `${estimated} · address bounced`;
|
|
783
|
+
const dark = member.activity.daysDark ?? 0;
|
|
784
|
+
const observed = dark === 0 ? "active today" : `quiet ${dark}d`;
|
|
785
|
+
return `${estimated} · ${observed} · health ${member.health ?? "—"}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const status = defineCommand({
|
|
789
|
+
meta: { name: "status", description: "Where a cohort stands: the clock, activity, the forecast, the trend" },
|
|
790
|
+
args: {
|
|
791
|
+
cohort: { type: "positional", description: "The cohort name or id" },
|
|
792
|
+
env: ENV_ARG,
|
|
793
|
+
"trend-days": { type: "string", description: "How many daily snapshots to include (default: 30)" },
|
|
794
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
795
|
+
},
|
|
796
|
+
run: ({ args }) =>
|
|
797
|
+
withErrorReporting(args.json, async () => {
|
|
798
|
+
const { db, d1, config, driver, modules } = await openTesters(args.env);
|
|
799
|
+
try {
|
|
800
|
+
const now = new Date();
|
|
801
|
+
const cohort = await modules.resolveCohortRef(
|
|
802
|
+
db,
|
|
803
|
+
required(args.cohort, "a cohort", "pithy testers status closed-test"),
|
|
804
|
+
);
|
|
805
|
+
const reading = await modules.readCohort(db, d1, cohort, config, now, readerLog(args.json));
|
|
806
|
+
const days = wholeNumber(args["trend-days"], "--trend-days", 30);
|
|
807
|
+
const snapshots = await modules.listSnapshots(db, cohort.id, days);
|
|
808
|
+
const view = modules.toCohortView(
|
|
809
|
+
reading,
|
|
810
|
+
config,
|
|
811
|
+
{ includeMembers: false, snapshots, latest: snapshots[snapshots.length - 1] },
|
|
812
|
+
now,
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
if (args.json) {
|
|
816
|
+
process.stdout.write(`${formatJsonLine({ command: "testers status", cohort: view })}\n`);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const clock = view.estimatedClock;
|
|
821
|
+
const activity = view.activity;
|
|
822
|
+
const projection = view.projection;
|
|
823
|
+
const percent = (value: number | null) => (value === null ? "—" : `${Math.round(value * 100)}%`);
|
|
824
|
+
|
|
825
|
+
process.stdout.write(`Cohort: ${view.name}\n`);
|
|
826
|
+
process.stdout.write(
|
|
827
|
+
`Estimated: ${view.roster.optedIn} of ${view.targetSize} opted in. Day ${clock.estimatedHeldDays} of ${view.windowDays}.\n`,
|
|
828
|
+
);
|
|
829
|
+
process.stdout.write(
|
|
830
|
+
`Observed: ${activity.active} active. ${activity.darkEightToThirteen + activity.darkFourteenPlus} quiet 8+ days. ${activity.neverLinked} never signed in.\n`,
|
|
831
|
+
);
|
|
832
|
+
const range = projection.successProbabilityRange;
|
|
833
|
+
process.stdout.write(
|
|
834
|
+
`Forecast: ${percent(projection.successProbability)}${
|
|
835
|
+
range ? ` (${percent(range.low)}–${percent(range.high)})` : ""
|
|
836
|
+
}. ${projection.confidence ?? "no"} confidence.\n`,
|
|
837
|
+
);
|
|
838
|
+
process.stdout.write(`Trend: ${capitalize(view.trend.direction)}. ${view.trend.reason}\n`);
|
|
839
|
+
if (projection.invitesNeeded > 0) {
|
|
840
|
+
process.stdout.write(`\nInvite ${projection.invitesNeeded} more.\n`);
|
|
841
|
+
}
|
|
842
|
+
if (view.trend.fragile) {
|
|
843
|
+
process.stdout.write(`\n${saffron("One lapse from a reset.")}\n`);
|
|
844
|
+
}
|
|
845
|
+
// Printed on every invocation, not behind a flag. A developer reading "day 13 of 14" is about
|
|
846
|
+
// to make a decision on it.
|
|
847
|
+
process.stdout.write(`\n${dim(ESTIMATE_NOTE)}\n`);
|
|
848
|
+
} finally {
|
|
849
|
+
await driver.dispose();
|
|
850
|
+
}
|
|
851
|
+
}),
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
const remove = defineCommand({
|
|
855
|
+
meta: { name: "remove", description: "Take a tester off a cohort's roster" },
|
|
856
|
+
args: {
|
|
857
|
+
cohort: { type: "positional", description: "The cohort name or id" },
|
|
858
|
+
email: { type: "string", description: "The tester's address", required: true },
|
|
859
|
+
reason: { type: "string", description: "A short note recorded on the event" },
|
|
860
|
+
env: ENV_ARG,
|
|
861
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
862
|
+
},
|
|
863
|
+
run: ({ args }) =>
|
|
864
|
+
withErrorReporting(args.json, async () => {
|
|
865
|
+
const { db, driver, modules } = await openTesters(args.env);
|
|
866
|
+
try {
|
|
867
|
+
const cohort = await modules.resolveCohortRef(
|
|
868
|
+
db,
|
|
869
|
+
required(args.cohort, "a cohort", "pithy testers status closed-test"),
|
|
870
|
+
);
|
|
871
|
+
const member = await modules.findMemberByEmail(db, cohort.id, String(args.email));
|
|
872
|
+
if (!member) {
|
|
873
|
+
throw new ValidationError({
|
|
874
|
+
message: "That address is not on this cohort.",
|
|
875
|
+
action: `Run \`pithy testers roster ${cohort.name}\` to see who is.`,
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
const removed = await modules.removeMember(
|
|
879
|
+
{ db, now: new Date(), newId: () => crypto.randomUUID() },
|
|
880
|
+
member.id,
|
|
881
|
+
args.reason,
|
|
882
|
+
);
|
|
883
|
+
if (args.json) {
|
|
884
|
+
process.stdout.write(
|
|
885
|
+
`${formatJsonLine({ command: "testers remove", member: { id: removed.id, email: removed.email } })}\n`,
|
|
886
|
+
);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
process.stdout.write(`${removed.email} removed from ${cohort.name}.\n`);
|
|
890
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
891
|
+
} finally {
|
|
892
|
+
await driver.dispose();
|
|
893
|
+
}
|
|
894
|
+
}),
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const close = defineCommand({
|
|
898
|
+
meta: { name: "close", description: "Close a cohort: keep its history, stop accruing snapshots and nudges" },
|
|
899
|
+
args: {
|
|
900
|
+
cohort: { type: "positional", description: "The cohort name or id" },
|
|
901
|
+
env: ENV_ARG,
|
|
902
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
903
|
+
},
|
|
904
|
+
run: ({ args }) =>
|
|
905
|
+
withErrorReporting(args.json, async () => {
|
|
906
|
+
const { db, driver, modules } = await openTesters(args.env);
|
|
907
|
+
try {
|
|
908
|
+
const cohort = await modules.resolveCohortRef(
|
|
909
|
+
db,
|
|
910
|
+
required(args.cohort, "a cohort", "pithy testers close closed-test"),
|
|
911
|
+
);
|
|
912
|
+
const closed = await modules.closeCohort({ db, now: new Date(), newId: () => crypto.randomUUID() }, cohort.id);
|
|
913
|
+
if (args.json) {
|
|
914
|
+
process.stdout.write(`${formatJsonLine({ command: "testers close", cohort: closed })}\n`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
process.stdout.write(`${closed.name} closed.\n`);
|
|
918
|
+
// The history stays queryable — a finished cohort is evidence, and the trend chart is the thing
|
|
919
|
+
// a developer shows when the store asks what testing they did.
|
|
920
|
+
process.stdout.write(dim("Its roster and trend stay readable. Nothing further is sent.\n"));
|
|
921
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
922
|
+
} finally {
|
|
923
|
+
await driver.dispose();
|
|
924
|
+
}
|
|
925
|
+
}),
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
const run = defineCommand({
|
|
929
|
+
meta: { name: "run", description: "Run the daily pass now: advance state, chase, and record the day" },
|
|
930
|
+
args: {
|
|
931
|
+
cohort: { type: "string", description: "Run one cohort only. Omit for every open cohort." },
|
|
932
|
+
env: ENV_ARG,
|
|
933
|
+
"skip-nudges": {
|
|
934
|
+
type: "boolean",
|
|
935
|
+
default: false,
|
|
936
|
+
description: "Advance state and record the day, but send nothing",
|
|
937
|
+
},
|
|
938
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
939
|
+
},
|
|
940
|
+
run: ({ args }) =>
|
|
941
|
+
withErrorReporting(args.json, async () => {
|
|
942
|
+
const { db, d1, config, driver, modules, enqueue } = await openTesters(args.env);
|
|
943
|
+
try {
|
|
944
|
+
// Real sends. The CLI needs no sending domain of its own: `enqueueEmail` writes a row into
|
|
945
|
+
// `pithy_email_jobs`, and the email worker that already exists for any project using auth
|
|
946
|
+
// delivers it. And nothing has to be minted, because the confirmation token lives on the member
|
|
947
|
+
// row — which is the whole reason it stopped being a signature.
|
|
948
|
+
const deps = {
|
|
949
|
+
db,
|
|
950
|
+
d1,
|
|
951
|
+
config,
|
|
952
|
+
now: new Date(),
|
|
953
|
+
newId: () => crypto.randomUUID(),
|
|
954
|
+
// The pass reports what it could not do — an unreadable suppression list, a roster it cannot
|
|
955
|
+
// see activity for. Same logger the read commands use, for the same reason.
|
|
956
|
+
log: readerLog(args.json),
|
|
957
|
+
enqueue: args["skip-nudges"] ? undefined : enqueue,
|
|
958
|
+
// The CLI resolves one database, the app `DB`. Without the suppression binding the pass leaves
|
|
959
|
+
// deliverability flags alone rather than inferring a bounce from a table it cannot read.
|
|
960
|
+
suppressionD1: undefined,
|
|
961
|
+
optOutLinkFor: (member: { optInToken: string }) => modules.optOutUrl(config, member.optInToken),
|
|
962
|
+
linkFor: args["skip-nudges"]
|
|
963
|
+
? undefined
|
|
964
|
+
: (kind: "confirm" | "store" | "inactive" | "closing", member: { optInToken: string }) =>
|
|
965
|
+
kind === "confirm"
|
|
966
|
+
? modules.confirmUrl(config, member.optInToken)
|
|
967
|
+
: kind === "store"
|
|
968
|
+
? modules.optInUrl(config, member.optInToken)
|
|
969
|
+
: undefined,
|
|
970
|
+
};
|
|
971
|
+
// Resolved rather than passed through raw: every sibling command takes a name or an id, and a
|
|
972
|
+
// name reaching `runCohortPass` as an id would 404 on a cohort the developer can see listed.
|
|
973
|
+
const results = args.cohort
|
|
974
|
+
? [await modules.runCohortPass(deps, (await modules.resolveCohortRef(db, args.cohort)).id)]
|
|
975
|
+
: await modules.runDailyPass(deps);
|
|
976
|
+
|
|
977
|
+
if (args.json) {
|
|
978
|
+
process.stdout.write(`${formatJsonLine({ command: "testers run", results })}\n`);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
for (const result of results) {
|
|
982
|
+
process.stdout.write(
|
|
983
|
+
`${result.cohortId}: ${result.estimatedOptedInCount} opted in, day ${result.estimatedHeldDays}. ${capitalize(result.trendDirection)}.\n`,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
// The truth, not a stale note. This line claimed nothing was sent long after the pass began
|
|
987
|
+
// sending for real — the kind of copy that teaches a developer to stop reading the output.
|
|
988
|
+
const queued = results.reduce(
|
|
989
|
+
(total, result) => total + Object.values(result.nudged).reduce((sum, count) => sum + count, 0),
|
|
990
|
+
0,
|
|
991
|
+
);
|
|
992
|
+
if (args["skip-nudges"] || !enqueue) {
|
|
993
|
+
process.stdout.write(
|
|
994
|
+
dim(
|
|
995
|
+
enqueue
|
|
996
|
+
? "Nothing was sent.\n"
|
|
997
|
+
: "Nothing was sent — no email capability is configured in this project.\n",
|
|
998
|
+
),
|
|
999
|
+
);
|
|
1000
|
+
} else {
|
|
1001
|
+
process.stdout.write(
|
|
1002
|
+
dim(`${queued} email${queued === 1 ? "" : "s"} queued. Delivery is the email worker's job.\n`),
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
1006
|
+
} finally {
|
|
1007
|
+
await driver.dispose();
|
|
1008
|
+
}
|
|
1009
|
+
}),
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
/** Sentence case for an enum value. */
|
|
1013
|
+
function capitalize(value: string): string {
|
|
1014
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export default defineCommand({
|
|
1018
|
+
meta: { name: "testers", description: "Run a closed test: cohorts, roster, the clock, and the daily pass" },
|
|
1019
|
+
subCommands: { provision, deprovision, create, list, invite, pending, roster, status, remove, close, run },
|
|
1020
|
+
});
|