@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,538 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { type CfAccount, listCloudflareAccounts } from "@pithy-sh/cloudflare/src/client/accounts";
|
|
6
|
+
import { DEFAULT_ENVIRONMENTS, DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
|
|
7
|
+
import { kebab } from "@pithy-sh/core/src/naming/resource";
|
|
8
|
+
import { PACKAGE_VERSION } from "@pithy-sh/core/src/version.generated";
|
|
9
|
+
import { defineCommand } from "citty";
|
|
10
|
+
import {
|
|
11
|
+
CloudflareAccountName,
|
|
12
|
+
type CloudflareConfigOptions,
|
|
13
|
+
cloudflareAccountFile,
|
|
14
|
+
cloudflareConfigPath,
|
|
15
|
+
resolveCloudflare,
|
|
16
|
+
writeCloudflareConfig,
|
|
17
|
+
} from "../cloudflare/config";
|
|
18
|
+
import { stateDir } from "../notifier/state";
|
|
19
|
+
import { askDomains, writeDomains } from "../project/askDomains";
|
|
20
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
21
|
+
import { renderDomainsBlock } from "../project/domainPrompt";
|
|
22
|
+
import { readOptionalFile } from "../project/readOptionalFile";
|
|
23
|
+
import { DEFAULT_WORKER, ensureScaffoldable, kitRange, scaffoldProject } from "../project/scaffold";
|
|
24
|
+
import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
|
|
25
|
+
import { dim } from "../terminal/style";
|
|
26
|
+
import { installAlias } from "./alias";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Offer the `p.` shortcut once, right after scaffolding (docs/CLI.md §2.7). Interactive only — a `--json`
|
|
30
|
+
* or non-TTY run never prompts. Asked at most once per project because a second `pithy init` in the same
|
|
31
|
+
* directory never reaches this line — it collides on the files the first one wrote — so there is no
|
|
32
|
+
* "asked before" flag to persist. On yes, the alias installs silently (its own Added/Reload lines are
|
|
33
|
+
* the only output).
|
|
34
|
+
*/
|
|
35
|
+
async function offerAlias(): Promise<void> {
|
|
36
|
+
const { isCancel, confirm } = await import("@clack/prompts");
|
|
37
|
+
const wants = await confirm({ message: "Want a shortcut? Type `p.` instead of `pithy`." });
|
|
38
|
+
if (!isCancel(wants) && wants) await installAlias({ silent: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether a human is attached — the only condition under which `init` prompts for anything. */
|
|
42
|
+
function interactive(json: boolean): boolean {
|
|
43
|
+
return !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ask one question, falling back to `fallback` when there is nobody to ask. A cancel aborts the whole
|
|
48
|
+
* run rather than proceeding on a value the user did not choose.
|
|
49
|
+
*/
|
|
50
|
+
async function ask(message: string, fallback: string, json: boolean): Promise<{ value: string; prompted: boolean }> {
|
|
51
|
+
if (!interactive(json)) return { value: fallback, prompted: false };
|
|
52
|
+
const { isCancel, text } = await import("@clack/prompts");
|
|
53
|
+
const answer = await text({ message, defaultValue: fallback, placeholder: fallback });
|
|
54
|
+
if (isCancel(answer)) {
|
|
55
|
+
process.stderr.write("Canceled.\n");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
return { value: answer, prompted: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The prompts `init` asks, as a seam.
|
|
63
|
+
*
|
|
64
|
+
* `@clack/prompts` needs a TTY, and the account flow below is the part of `init` most worth testing:
|
|
65
|
+
* it lists accounts, slugifies a free-text name into something that becomes a *file name*, and decides
|
|
66
|
+
* what goes into the repository. A suite that could only reach it through a terminal would not reach it.
|
|
67
|
+
*/
|
|
68
|
+
export interface InitPrompt {
|
|
69
|
+
text(options: { message: string; defaultValue?: string; placeholder?: string }): Promise<string | symbol>;
|
|
70
|
+
password(options: { message: string }): Promise<string | symbol>;
|
|
71
|
+
select(options: { message: string; options: { value: string; label: string }[] }): Promise<string | symbol>;
|
|
72
|
+
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean | symbol>;
|
|
73
|
+
isCancel(value: unknown): boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The real prompts. Imported lazily, as every other `@clack/prompts` use here is. */
|
|
77
|
+
async function clackPrompt(): Promise<InitPrompt> {
|
|
78
|
+
const clack = await import("@clack/prompts");
|
|
79
|
+
return {
|
|
80
|
+
text: (options) => clack.text(options),
|
|
81
|
+
password: (options) => clack.password(options),
|
|
82
|
+
select: (options) => clack.select(options) as Promise<string | symbol>,
|
|
83
|
+
confirm: (options) => clack.confirm(options),
|
|
84
|
+
isCancel: (value) => clack.isCancel(value as never),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What {@link askEnvironments} decided. */
|
|
89
|
+
export interface EnvironmentsAnswer {
|
|
90
|
+
/** The environments the project has. Always valid, always at least one. */
|
|
91
|
+
environments: DeclaredEnvironments;
|
|
92
|
+
/** Whether anything was actually asked. */
|
|
93
|
+
prompted: boolean;
|
|
94
|
+
/** Whether the answer differs from the default — the only case that writes a line into the config. */
|
|
95
|
+
declared: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What {@link askEnvironments} needs. Both seams default to the real prompts and stderr. */
|
|
99
|
+
export interface AskEnvironmentsOptions {
|
|
100
|
+
/** Whether a human is attached. False asks nothing and takes the default. */
|
|
101
|
+
interactive: boolean;
|
|
102
|
+
/** Seam: the prompts. Defaults to `@clack/prompts`. */
|
|
103
|
+
prompt?: InitPrompt;
|
|
104
|
+
/** Seam: where a re-ask explains itself. Defaults to stdout. */
|
|
105
|
+
note?: (line: string) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** How a typed answer is split: commas, whitespace, or both. `staging,prod` and `staging, prod` are one answer. */
|
|
109
|
+
function parseEnvironments(answer: string): string[] {
|
|
110
|
+
return answer
|
|
111
|
+
.split(/[,\s]+/)
|
|
112
|
+
.map((environment) => environment.trim())
|
|
113
|
+
.filter((environment) => environment.length > 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Ask which environments this project has (#241).
|
|
118
|
+
*
|
|
119
|
+
* **Asked here for the same reason `name` is.** `<project>-<env>-<thing>` has a 33-character ceiling;
|
|
120
|
+
* `name` is capped at 26 and asked at `init` because a provisioned project cannot be renamed. The
|
|
121
|
+
* environment sits in the middle of that same name, is measured against the same ceiling, and was asked
|
|
122
|
+
* about nowhere — it was scaffolded from a template and then contradicted by a closed enum in one place
|
|
123
|
+
* and an open policy field in another. One of the two segments was a decision; the other was an accident.
|
|
124
|
+
*
|
|
125
|
+
* **One keypress for the common case.** The default is offered as the placeholder, so an adopter who has
|
|
126
|
+
* never thought about this presses enter and gets exactly what the scaffold always wrote — and no
|
|
127
|
+
* `environments` line in their config, because a declaration that repeats the default teaches nothing.
|
|
128
|
+
*
|
|
129
|
+
* **An illegal answer is re-asked, not accepted.** `dev` is the one people try, and it is the one that
|
|
130
|
+
* has to be refused: it is local, always present, and never deployed. Three attempts, then the default —
|
|
131
|
+
* a scaffold that cannot be completed is worse than one on the default set.
|
|
132
|
+
*
|
|
133
|
+
* **Skipped when nobody is at a terminal**, exactly as the account and domain questions are. CI scaffolds
|
|
134
|
+
* projects, there is nobody to ask, and the default is what the template already held.
|
|
135
|
+
*/
|
|
136
|
+
export async function askEnvironments(options: AskEnvironmentsOptions): Promise<EnvironmentsAnswer> {
|
|
137
|
+
const fallback: EnvironmentsAnswer = {
|
|
138
|
+
environments: [...DEFAULT_ENVIRONMENTS],
|
|
139
|
+
prompted: false,
|
|
140
|
+
declared: false,
|
|
141
|
+
};
|
|
142
|
+
if (!options.interactive) return fallback;
|
|
143
|
+
|
|
144
|
+
const prompt = options.prompt ?? (await clackPrompt());
|
|
145
|
+
const note = options.note ?? ((line: string) => void process.stdout.write(`${line}\n`));
|
|
146
|
+
const offered = DEFAULT_ENVIRONMENTS.join(", ");
|
|
147
|
+
|
|
148
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
149
|
+
const answer = asString(
|
|
150
|
+
await prompt.text({
|
|
151
|
+
message: "Environments this project deploys to:",
|
|
152
|
+
defaultValue: offered,
|
|
153
|
+
placeholder: offered,
|
|
154
|
+
}),
|
|
155
|
+
prompt,
|
|
156
|
+
);
|
|
157
|
+
// Canceled. The default is the honest fallback: it is what the template holds, and it is what a
|
|
158
|
+
// non-interactive run gets, so nothing is scaffolded that an unanswered question decided.
|
|
159
|
+
if (answer === null) return { ...fallback, prompted: true };
|
|
160
|
+
|
|
161
|
+
const parsed = DeclaredEnvironments.safeParse(parseEnvironments(answer));
|
|
162
|
+
if (parsed.success) {
|
|
163
|
+
const declared = parsed.data.join(", ") !== offered;
|
|
164
|
+
return { environments: parsed.data, prompted: true, declared };
|
|
165
|
+
}
|
|
166
|
+
// The schema's own sentences — `dev` gets the reason it is not declarable, an over-long name gets the
|
|
167
|
+
// number. Re-asked rather than refused, because this is a prompt and the adopter is still here.
|
|
168
|
+
for (const issue of parsed.error.issues) note(issue.message);
|
|
169
|
+
}
|
|
170
|
+
return { ...fallback, prompted: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The `cloudflare` block `init` writes into the root config, or `null` when it discovered nothing. */
|
|
174
|
+
export interface CloudflareBlock {
|
|
175
|
+
/** The nickname selecting `<config>/cloudflare.<name>.json`. Always a validated bare token. */
|
|
176
|
+
accountName?: string;
|
|
177
|
+
/** The pin — written by default, declinable. Always the id read from the account, never one typed. */
|
|
178
|
+
accountId?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What {@link askCloudflareAccount} decided. */
|
|
182
|
+
export interface CloudflareAccountAnswer {
|
|
183
|
+
/** The block to write into `pithy.config.ts`, or `null` when nothing named an account. */
|
|
184
|
+
block: CloudflareBlock | null;
|
|
185
|
+
/** Lines to print. Paths and names only — never a token. */
|
|
186
|
+
notes: string[];
|
|
187
|
+
/** Whether anything was actually asked. */
|
|
188
|
+
prompted: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What {@link askCloudflareAccount} needs. Both seams default to the real account and the real prompts. */
|
|
192
|
+
export interface AskCloudflareAccountOptions {
|
|
193
|
+
/** Whether a human is attached. False asks nothing and writes nothing. */
|
|
194
|
+
interactive: boolean;
|
|
195
|
+
/** Where the config directory is, and which account inside it. Defaults to the real one. */
|
|
196
|
+
paths?: CloudflareConfigOptions;
|
|
197
|
+
/** Seam: the accounts a token can see. Defaults to the real REST call. */
|
|
198
|
+
listAccounts?: (apiToken: string) => Promise<CfAccount[]>;
|
|
199
|
+
/** Seam: the prompts. Defaults to `@clack/prompts`. */
|
|
200
|
+
prompt?: InitPrompt;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Record the Cloudflare credentials and discover which account they belong to.
|
|
205
|
+
*
|
|
206
|
+
* **This is the one moment the operator is holding the token**, which is the whole reason it belongs in
|
|
207
|
+
* `init`: the token is needed to list zones two prompts later, and every command after this needs it.
|
|
208
|
+
* The credentials are account-scoped, so they are written to `<config>/cloudflare.json` — or, once an
|
|
209
|
+
* account has a name, `<config>/cloudflare.<name>.json` — outside every checkout, `0600`, in the `0700`
|
|
210
|
+
* config directory (#182, #206). Nothing minted or typed here goes into the project except the account's
|
|
211
|
+
* *name* and *id*, and neither of those is a secret.
|
|
212
|
+
*
|
|
213
|
+
* **The account is discovered, not asked for.** `init` used to take an account id on trust: pasted,
|
|
214
|
+
* unverified, and wrong often enough that a token for one account against another account's id is a
|
|
215
|
+
* documented failure mode. The token can list the accounts it can see, so it is asked — one account is a
|
|
216
|
+
* confirmation, several are a picker, and choosing the account *is* choosing the nickname. A narrowly
|
|
217
|
+
* scoped token that cannot list falls back to asking for the id, exactly as before, and `init` completes.
|
|
218
|
+
*
|
|
219
|
+
* **A slugified Cloudflare name goes through the same schema as a typed one.** Account names are free
|
|
220
|
+
* text (`Leed, Inc.`), and the result becomes a file name — so there is one validation, not two, or the
|
|
221
|
+
* rule would only be true of one of the ways a name arrives. A name that slugifies to nothing is asked
|
|
222
|
+
* for rather than guessed at.
|
|
223
|
+
*
|
|
224
|
+
* **Skipped when nobody is at a terminal**, for the same reason every other prompt here is: CI scaffolds
|
|
225
|
+
* projects and there is nobody to ask, so it writes no `cloudflare` block and resolves `cloudflare.json`
|
|
226
|
+
* exactly as it always has. Skipped, too, when the credentials already resolve *and* the token sees one
|
|
227
|
+
* account — that is the single-account machine, and it has nothing to be asked about.
|
|
228
|
+
*/
|
|
229
|
+
export async function askCloudflareAccount(options: AskCloudflareAccountOptions): Promise<CloudflareAccountAnswer> {
|
|
230
|
+
const nothing: CloudflareAccountAnswer = { block: null, notes: [], prompted: false };
|
|
231
|
+
if (!options.interactive) return nothing;
|
|
232
|
+
|
|
233
|
+
const paths = options.paths ?? { account: null };
|
|
234
|
+
const listAccounts = options.listAccounts ?? ((apiToken: string) => listCloudflareAccounts({ apiToken }));
|
|
235
|
+
const prompt = options.prompt ?? (await clackPrompt());
|
|
236
|
+
const existing = resolveCloudflare(paths).vars;
|
|
237
|
+
const configured = Boolean(existing.CLOUDFLARE_ACCOUNT_ID && existing.CLOUDFLARE_API_TOKEN);
|
|
238
|
+
|
|
239
|
+
if (!configured) {
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
`${dim("Cloudflare credentials are per account, not per project — one set for every project on that account.")}\n${dim(`They are kept under ${stateDir(paths)}, never in this repository.`)}\n${dim("Press enter to skip; pithy doctor names them until they are set.")}\n\n`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The token first, because it is what the account listing needs. An already-resolving one is reused:
|
|
246
|
+
// nothing asks for a credential the machine already holds.
|
|
247
|
+
const apiToken =
|
|
248
|
+
existing.CLOUDFLARE_API_TOKEN ?? asString(await prompt.password({ message: "Cloudflare API token:" }), prompt);
|
|
249
|
+
// Canceled, or skipped with an empty answer. Both are legitimate: a project scaffolded before the
|
|
250
|
+
// account exists is an ordinary thing to do, and `pithy doctor` names the missing keys until they are set.
|
|
251
|
+
if (!apiToken) return nothing;
|
|
252
|
+
|
|
253
|
+
// Never fatal, and no longer one fact (#378). `null` is "could not ask" — an unreachable network, a
|
|
254
|
+
// token without `account:read`, a revoked token; `[]` is "this token sees no account". Collapsing them
|
|
255
|
+
// into `[]` is the origin of every confidently-empty listing downstream: the operator then types an id
|
|
256
|
+
// nothing verified, and it becomes the `CLOUDFLARE_ACCOUNT_ID` every later command asks about.
|
|
257
|
+
const accounts = await listAccounts(apiToken).catch(() => null);
|
|
258
|
+
|
|
259
|
+
// A configured machine whose token sees one account is the single-account case #182 was written for.
|
|
260
|
+
// It has nothing to choose, so it is not asked — this is the run that must stay exactly as it was.
|
|
261
|
+
if (configured && accounts !== null && accounts.length <= 1) return nothing;
|
|
262
|
+
|
|
263
|
+
if (accounts === null || accounts.length === 0) {
|
|
264
|
+
return await askAccountIdOnly({ apiToken, existing, paths, prompt, reachable: accounts !== null });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const chosen = await chooseAccount(accounts, prompt);
|
|
268
|
+
if (chosen === null) return nothing;
|
|
269
|
+
|
|
270
|
+
const named = await askAccountName(chosen, prompt);
|
|
271
|
+
if (named === null) return nothing;
|
|
272
|
+
|
|
273
|
+
const pin = await prompt.confirm({
|
|
274
|
+
message: `Pin account ${chosen.id} in pithy.config.ts? It is an identifier, not a secret, and it is what stops another machine's "${named.accountName}" from meaning a different account.`,
|
|
275
|
+
initialValue: true,
|
|
276
|
+
});
|
|
277
|
+
const pinned = !prompt.isCancel(pin) && pin === true;
|
|
278
|
+
|
|
279
|
+
const account = { accountName: named.accountName };
|
|
280
|
+
await writeCloudflareConfig(
|
|
281
|
+
{ CLOUDFLARE_ACCOUNT_ID: chosen.id, CLOUDFLARE_API_TOKEN: apiToken },
|
|
282
|
+
{ ...paths, account },
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
// The path and the account's name, never a value. A token echoed into a terminal is a token in a
|
|
287
|
+
// scrollback and a CI log.
|
|
288
|
+
notes: [...named.notes, `Recorded ${chosen.name}'s credentials in ${cloudflareConfigPath({ ...paths, account })}.`],
|
|
289
|
+
block: { accountName: named.accountName, ...(pinned ? { accountId: chosen.id } : {}) },
|
|
290
|
+
prompted: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The fallback: no picker, so ask for an id exactly as `init` always asked — and say why there was none.
|
|
296
|
+
*
|
|
297
|
+
* **The two reasons are not one reason (#378).** A token that reached Cloudflare and was shown no account
|
|
298
|
+
* is a scoping problem the operator can fix; a call that never landed says nothing about the account at
|
|
299
|
+
* all. Both used to print the same bare prompt, and the id typed under either of them became the pin
|
|
300
|
+
* every later listing is compared against. Naming which one happened is the difference between "widen the
|
|
301
|
+
* token" and "check the network", and it is the last moment anyone is looking.
|
|
302
|
+
*/
|
|
303
|
+
async function askAccountIdOnly(context: {
|
|
304
|
+
apiToken: string;
|
|
305
|
+
existing: Record<string, string>;
|
|
306
|
+
paths: CloudflareConfigOptions;
|
|
307
|
+
prompt: InitPrompt;
|
|
308
|
+
/** Whether the account listing was actually answered. `false` is "could not ask", never "sees none". */
|
|
309
|
+
reachable: boolean;
|
|
310
|
+
}): Promise<CloudflareAccountAnswer> {
|
|
311
|
+
const { apiToken, existing, paths, prompt, reachable } = context;
|
|
312
|
+
process.stdout.write(
|
|
313
|
+
`${dim(
|
|
314
|
+
reachable
|
|
315
|
+
? "This token sees no Cloudflare account, so there is nothing to pick from. Widen it to account:read, or paste the id."
|
|
316
|
+
: "Cloudflare could not be reached, so the account list is unknown. The id below is recorded as typed and verified by nothing.",
|
|
317
|
+
)}\n`,
|
|
318
|
+
);
|
|
319
|
+
const accountId =
|
|
320
|
+
existing.CLOUDFLARE_ACCOUNT_ID ??
|
|
321
|
+
asString(await prompt.text({ message: "Cloudflare account id:", defaultValue: "" }), prompt);
|
|
322
|
+
if (accountId === null) return { block: null, notes: [], prompted: true };
|
|
323
|
+
|
|
324
|
+
const written = await writeCloudflareConfig(
|
|
325
|
+
{ ...(accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}), CLOUDFLARE_API_TOKEN: apiToken },
|
|
326
|
+
{ ...paths, account: null },
|
|
327
|
+
);
|
|
328
|
+
return {
|
|
329
|
+
// No block: nothing discovered a name, and inventing one at a prompt is the guess this replaced.
|
|
330
|
+
block: null,
|
|
331
|
+
notes: [
|
|
332
|
+
`Recorded ${Object.keys(written).sort().join(" and ")} in ${cloudflareConfigPath({ ...paths, account: null })}.`,
|
|
333
|
+
],
|
|
334
|
+
prompted: true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One account, confirmed or picked. Choosing the account is what supplies both the id and the name. */
|
|
339
|
+
async function chooseAccount(accounts: CfAccount[], prompt: InitPrompt): Promise<CfAccount | null> {
|
|
340
|
+
const only = accounts[0];
|
|
341
|
+
if (accounts.length === 1 && only) return only;
|
|
342
|
+
const choice = await prompt.select({
|
|
343
|
+
message: "Which Cloudflare account is this project for?",
|
|
344
|
+
options: accounts.map((account) => ({ value: account.id, label: `${account.name} (${account.id})` })),
|
|
345
|
+
});
|
|
346
|
+
if (prompt.isCancel(choice)) return null;
|
|
347
|
+
return accounts.find((account) => account.id === choice) ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The nickname for the account's credentials file, offered as the account's own slugified name.
|
|
352
|
+
*
|
|
353
|
+
* The slug and the typed answer go through {@link CloudflareAccountName} at the same line — that is the
|
|
354
|
+
* point. `kebab` is core's, the same normalizer every project name goes through, so `Leed, Inc.` offers
|
|
355
|
+
* `leed-inc`; a name that slugifies to nothing offers nothing and asks.
|
|
356
|
+
*/
|
|
357
|
+
async function askAccountName(
|
|
358
|
+
account: CfAccount,
|
|
359
|
+
prompt: InitPrompt,
|
|
360
|
+
): Promise<{ accountName: string; notes: string[] } | null> {
|
|
361
|
+
const suggestion = kebab(account.name);
|
|
362
|
+
const notes: string[] = [];
|
|
363
|
+
const defaultValue = CloudflareAccountName.safeParse(suggestion).success ? suggestion : "";
|
|
364
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
365
|
+
const answer = asString(
|
|
366
|
+
await prompt.text({
|
|
367
|
+
// The account is named in the question, so a single-account run confirms *which* account it
|
|
368
|
+
// used in the same keypress that accepts the nickname — nothing is chosen silently.
|
|
369
|
+
message: `Credentials file for ${account.name} (${account.id}) — <config>/cloudflare.<name>.json. Name:`,
|
|
370
|
+
defaultValue,
|
|
371
|
+
placeholder: defaultValue,
|
|
372
|
+
}),
|
|
373
|
+
prompt,
|
|
374
|
+
);
|
|
375
|
+
if (answer === null) return null;
|
|
376
|
+
const parsed = CloudflareAccountName.safeParse(answer.trim());
|
|
377
|
+
if (parsed.success) return { accountName: parsed.data, notes };
|
|
378
|
+
notes.push(`"${answer.trim()}" is not a bare token — lowercase letters, digits, and single hyphens.`);
|
|
379
|
+
process.stdout.write(`${notes[notes.length - 1]}\n`);
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A prompt's answer as a string, or `null` when the operator canceled. */
|
|
385
|
+
function asString(value: string | symbol, prompt: InitPrompt): string | null {
|
|
386
|
+
if (prompt.isCancel(value)) return null;
|
|
387
|
+
return typeof value === "string" ? value : null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The `cloudflare` block, as an adopter would write it by hand. */
|
|
391
|
+
export function renderCloudflareBlock(block: CloudflareBlock): string {
|
|
392
|
+
const lines = [
|
|
393
|
+
" // Which Cloudflare account this project belongs to. `accountName` selects",
|
|
394
|
+
" // <config>/cloudflare.<name>.json; `accountId` pins the account those credentials",
|
|
395
|
+
" // must belong to. An account id is an identifier, not a secret.",
|
|
396
|
+
" cloudflare: {",
|
|
397
|
+
...(block.accountName ? [` accountName: ${JSON.stringify(block.accountName)},`] : []),
|
|
398
|
+
...(block.accountId ? [` accountId: ${JSON.stringify(block.accountId)},`] : []),
|
|
399
|
+
" },",
|
|
400
|
+
];
|
|
401
|
+
return `${lines.join("\n")}\n`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Where the block is spliced into the scaffolded config: immediately inside the object it exports. */
|
|
405
|
+
const CONFIG_OPEN = "const config = {\n";
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Write the `cloudflare` block into the scaffolded root `pithy.config.ts`.
|
|
409
|
+
*
|
|
410
|
+
* **The name is validated again on the way in.** It has already been through
|
|
411
|
+
* {@link CloudflareAccountName} at the prompt, and it goes through it here too, because this is the
|
|
412
|
+
* function that puts it in a file every later command builds a path from.
|
|
413
|
+
*
|
|
414
|
+
* A config this cannot place the block in is reported rather than rewritten — `init` prints the block
|
|
415
|
+
* for the adopter to paste, the same way an unplaceable `domains` declaration is handled. Guessing at
|
|
416
|
+
* the shape of a file somebody may have already edited is how a scaffold eats an edit.
|
|
417
|
+
*/
|
|
418
|
+
export async function writeCloudflareBlock(projectDir: string, block: CloudflareBlock): Promise<{ declared: boolean }> {
|
|
419
|
+
if (block.accountName !== undefined) cloudflareAccountFile(block.accountName);
|
|
420
|
+
const path = join(projectDir, "pithy.config.ts");
|
|
421
|
+
const source = await readOptionalFile(path);
|
|
422
|
+
if (source === null || !source.includes(CONFIG_OPEN)) return { declared: false };
|
|
423
|
+
const at = source.indexOf(CONFIG_OPEN) + CONFIG_OPEN.length;
|
|
424
|
+
await writeFileAtomic(path, `${source.slice(0, at)}${renderCloudflareBlock(block)}\n${source.slice(at)}`);
|
|
425
|
+
return { declared: true };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export default defineCommand({
|
|
429
|
+
meta: { name: "init", description: "Scaffold a new project" },
|
|
430
|
+
args: {
|
|
431
|
+
name: { type: "string", description: "Application name. Defaults to the directory name." },
|
|
432
|
+
worker: {
|
|
433
|
+
type: "string",
|
|
434
|
+
description: `Name of the first worker, created at apps/<name>. Defaults to "${DEFAULT_WORKER}".`,
|
|
435
|
+
},
|
|
436
|
+
dir: {
|
|
437
|
+
type: "string",
|
|
438
|
+
default: ".",
|
|
439
|
+
description: "Target directory. Created if missing; must not already hold the files init writes.",
|
|
440
|
+
},
|
|
441
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
442
|
+
},
|
|
443
|
+
run: ({ args }) =>
|
|
444
|
+
withErrorReporting(args.json, async () => {
|
|
445
|
+
const targetDir = resolve(process.cwd(), args.dir);
|
|
446
|
+
// Gate on the target before prompting — a doomed run fails fast, not after
|
|
447
|
+
// the user answers (scaffoldProject re-checks, so the guard still holds).
|
|
448
|
+
// `--worker` is passed when given: it decides which `apps/<name>` the scaffold lands at, and so
|
|
449
|
+
// what counts as a collision. A prompted worker name is only known later, and the re-check covers
|
|
450
|
+
// it. `ensureScaffoldable` validates the name before it builds a path out of it.
|
|
451
|
+
await ensureScaffoldable(targetDir, args.worker);
|
|
452
|
+
|
|
453
|
+
// Said before the question, not after it. The name leads every Cloudflare resource this project
|
|
454
|
+
// provisions (docs/NAMING.md), teardown recomputes those names rather than storing them, and the
|
|
455
|
+
// scope decision behind it — one project or two — cannot be undone by editing a string later.
|
|
456
|
+
if (!args.name && interactive(args.json)) {
|
|
457
|
+
process.stdout.write(
|
|
458
|
+
`${dim("One project per set of apps that share users or data. Another app? Add a worker, not a project.")}\n${dim("The name leads every Cloudflare resource this project provisions. Changing it later orphans them.")}\n\n`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const name = args.name
|
|
462
|
+
? { value: args.name, prompted: false }
|
|
463
|
+
: await ask("Project name:", basename(targetDir), args.json);
|
|
464
|
+
// Every worker lives in apps/<name>, so the first one is named too. `api` is the default because
|
|
465
|
+
// this scaffold is a backend — a project that wants `web`, `edge`, or `admin` says so here.
|
|
466
|
+
const worker = args.worker
|
|
467
|
+
? { value: args.worker, prompted: false }
|
|
468
|
+
: await ask("First worker (apps/<name>):", DEFAULT_WORKER, args.json);
|
|
469
|
+
|
|
470
|
+
// Asked with the two names, before anything is written, because it is the same kind of answer:
|
|
471
|
+
// the environment is the second segment of every Cloudflare name this project will compose, and
|
|
472
|
+
// renaming one after provisioning orphans what was created under the old one. The scaffold writes
|
|
473
|
+
// each Worker's `env.<name>` stanzas from it.
|
|
474
|
+
const environments = await askEnvironments({ interactive: interactive(args.json) });
|
|
475
|
+
|
|
476
|
+
const appName = name.value;
|
|
477
|
+
await scaffoldProject({ targetDir, appName, worker: worker.value, environments: environments.environments });
|
|
478
|
+
|
|
479
|
+
// Asked before the zones are, because listing them needs the token — and the same token answers
|
|
480
|
+
// which account this project is for, so the two questions collapse into one.
|
|
481
|
+
const account = await askCloudflareAccount({ interactive: interactive(args.json) });
|
|
482
|
+
const credentials = [...account.notes];
|
|
483
|
+
// The block goes into the config the scaffold just wrote, so `loadProject` reads it on the very
|
|
484
|
+
// next command. A config it could not be placed in is printed rather than dropped.
|
|
485
|
+
const declared = account.block ? await writeCloudflareBlock(targetDir, account.block) : null;
|
|
486
|
+
|
|
487
|
+
// Where the Worker answers, asked against the account's real zones. Skippable — a project without
|
|
488
|
+
// a domain yet is legitimate, and adding one later is a config edit plus a deploy. Asked after the
|
|
489
|
+
// scaffold exists, because the answer is written into files this just created.
|
|
490
|
+
const asked = await askDomains({
|
|
491
|
+
projectDir: targetDir,
|
|
492
|
+
// The account this run just recorded — the zones offered below belong to it, not to whatever
|
|
493
|
+
// `cloudflare.json` happens to hold on this machine.
|
|
494
|
+
account: account.block,
|
|
495
|
+
workerName: worker.value,
|
|
496
|
+
interactive: interactive(args.json),
|
|
497
|
+
});
|
|
498
|
+
// A declaration the writer could not place must not vanish. `writeDomains` still generates the
|
|
499
|
+
// wrangler values either way, so the Worker routes correctly — but the `domains` block is the
|
|
500
|
+
// source of truth, and an adopter who answered the prompt needs to know it did not land.
|
|
501
|
+
const wrote = asked.domains ? await writeDomains(join(targetDir, "apps", worker.value), asked.domains) : null;
|
|
502
|
+
const prompted = name.prompted || worker.prompted || environments.prompted || asked.prompted || account.prompted;
|
|
503
|
+
|
|
504
|
+
if (args.json) {
|
|
505
|
+
process.stdout.write(
|
|
506
|
+
`${formatJsonLine({ command: "init", targetDir, appName, worker: worker.value, environments: environments.environments, domains: asked.domains ?? null })}\n`,
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
for (const note of credentials) process.stdout.write(`${note}\n`);
|
|
511
|
+
if (declared && !declared.declared && account.block) {
|
|
512
|
+
process.stdout.write(
|
|
513
|
+
`Could not write the cloudflare block into pithy.config.ts. Add it by hand:\n${renderCloudflareBlock(account.block)}`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (wrote && !wrote.declared && asked.domains) {
|
|
517
|
+
process.stdout.write(
|
|
518
|
+
`Could not write the domains block into pithy.config.ts. Add it by hand:\n${renderDomainsBlock(asked.domains)}\n`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
// The scaffolded worker declares no kit dependency while nothing under `@pithy-sh/*` is published
|
|
522
|
+
// ({@link kitRange}), so say where the kit comes from instead. Unsaid, the adopter meets the gap as
|
|
523
|
+
// an unresolved import at the first typecheck — which is a worse introduction than a sentence here,
|
|
524
|
+
// and the only alternative is the range that 404s their first install.
|
|
525
|
+
if (kitRange(PACKAGE_VERSION) === null) {
|
|
526
|
+
process.stdout.write(
|
|
527
|
+
`${dim("@pithy-sh/* isn't published yet, so this worker declares no kit dependency.")}\n${dim("Link the kit from a checkout, then install.")}\n`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
// Set Done. off from the prompt's gutter; a flagged run has nothing above it.
|
|
531
|
+
process.stdout.write(`${prompted ? "\n" : ""}${formatDone()}\n`);
|
|
532
|
+
|
|
533
|
+
// Offer the shortcut only to a human at a terminal — never in a --json or piped run.
|
|
534
|
+
if (!args.json && process.stdin.isTTY && process.stdout.isTTY) {
|
|
535
|
+
await offerAlias();
|
|
536
|
+
}
|
|
537
|
+
}),
|
|
538
|
+
});
|