create-feedbax 0.1.0 → 0.2.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 +201 -21
- package/README.md +11 -1
- package/dist/cli.js +1261 -26
- package/dist/index.js +1402 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/LICENSE +201 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +25 -0
- package/template/package.json +70 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +204 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +219 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +18 -0
- package/template/src/comment-creation.test.tsx +270 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +18 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +246 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +309 -0
- package/template/src/components/header.tsx +95 -0
- package/template/src/components/loader.tsx +10 -0
- package/template/src/components/profile-menu.tsx +89 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +194 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +52 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- package/template/src/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +97 -0
- package/template/src/feedback-runtime.ts +38 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +111 -0
- package/template/src/feedbax/config/index.ts +271 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +942 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/index.css +2138 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +10 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +130 -0
- package/template/src/overlay-post-detail.tsx +92 -0
- package/template/src/portal-comments.test.ts +375 -0
- package/template/src/portal-comments.ts +384 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +515 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +55 -0
- package/template/src/portal-feedback-server-function.ts +93 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +214 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +64 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +141 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +124 -0
- package/template/src/public-feedback-page.test.tsx +290 -0
- package/template/src/public-feedback-page.ts +75 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail.tsx +682 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +214 -0
- package/template/src/public-post-route.tsx +171 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +218 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +378 -0
- package/template/src/public-shell.test.tsx +518 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +131 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +25 -0
- package/template/src/routes/__root.tsx +116 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +45 -0
- package/template/src/server.ts +111 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +211 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/README.md +0 -7
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
package/dist/cli.js
CHANGED
|
@@ -1,28 +1,1263 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import * as prompt from "@clack/prompts";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
7
|
+
import { basename as basename2, resolve as resolve2 } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
import { readFile as readFile2, readdir as readdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
12
|
+
|
|
13
|
+
// ../config/src/index.ts
|
|
14
|
+
var rootKeys = [
|
|
15
|
+
"product",
|
|
16
|
+
"theme",
|
|
17
|
+
"notion",
|
|
18
|
+
"changelog",
|
|
19
|
+
"features",
|
|
20
|
+
"submissions",
|
|
21
|
+
"turnstile"
|
|
22
|
+
];
|
|
23
|
+
var featureKeys = ["voting", "comments", "changelog"];
|
|
24
|
+
var feedbackPropertyKeys = [
|
|
25
|
+
"title",
|
|
26
|
+
"slug",
|
|
27
|
+
"description",
|
|
28
|
+
"type",
|
|
29
|
+
"status",
|
|
30
|
+
"published",
|
|
31
|
+
"voteCount",
|
|
32
|
+
"teamResponse",
|
|
33
|
+
"releaseNotesUrl",
|
|
34
|
+
"submitterName",
|
|
35
|
+
"submitterEmail",
|
|
36
|
+
"source",
|
|
37
|
+
"externalId",
|
|
38
|
+
"editTokenHash",
|
|
39
|
+
"createdAt",
|
|
40
|
+
"updatedAt"
|
|
41
|
+
];
|
|
42
|
+
var changelogPropertyKeys = [
|
|
43
|
+
"title",
|
|
44
|
+
"slug",
|
|
45
|
+
"date",
|
|
46
|
+
"summary",
|
|
47
|
+
"body",
|
|
48
|
+
"labels",
|
|
49
|
+
"image",
|
|
50
|
+
"published",
|
|
51
|
+
"createdAt",
|
|
52
|
+
"updatedAt"
|
|
53
|
+
];
|
|
54
|
+
function defineFeedbax(config) {
|
|
55
|
+
const value = object(config, "Feedbax configuration");
|
|
56
|
+
rejectUnknown(value, rootKeys, "Feedbax configuration");
|
|
57
|
+
const product = object(value.product, "product");
|
|
58
|
+
rejectUnknown(
|
|
59
|
+
product,
|
|
60
|
+
["name", "description", "logo", "siteUrl", "supportUrl", "privacyUrl"],
|
|
61
|
+
"product"
|
|
62
|
+
);
|
|
63
|
+
const validatedProduct = {
|
|
64
|
+
name: text(product.name, "product.name"),
|
|
65
|
+
description: text(product.description, "product.description"),
|
|
66
|
+
logo: publicUrl(product.logo, "product.logo", true),
|
|
67
|
+
...optionalPublicUrl(product, "siteUrl"),
|
|
68
|
+
...optionalPublicUrl(product, "supportUrl"),
|
|
69
|
+
...optionalPublicUrl(product, "privacyUrl")
|
|
70
|
+
};
|
|
71
|
+
const theme = object(value.theme, "theme");
|
|
72
|
+
rejectUnknown(theme, ["preset"], "theme");
|
|
73
|
+
const notion = storage(
|
|
74
|
+
value.notion,
|
|
75
|
+
feedbackPropertyKeys,
|
|
76
|
+
"notion"
|
|
77
|
+
);
|
|
78
|
+
const features = object(value.features, "features");
|
|
79
|
+
rejectUnknown(features, featureKeys, "features");
|
|
80
|
+
const validatedFeatures = Object.fromEntries(
|
|
81
|
+
featureKeys.map((key) => {
|
|
82
|
+
if (typeof features[key] !== "boolean")
|
|
83
|
+
throw new Error(`features.${key} must be true or false.`);
|
|
84
|
+
return [key, features[key]];
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
const changelog = value.changelog === void 0 ? void 0 : storage(
|
|
88
|
+
value.changelog,
|
|
89
|
+
changelogPropertyKeys,
|
|
90
|
+
"changelog"
|
|
91
|
+
);
|
|
92
|
+
if (validatedFeatures.changelog && !changelog)
|
|
93
|
+
throw new Error(
|
|
94
|
+
"Changelog is enabled but changelog storage is not configured. Add changelog identifiers and property mappings, or set features.changelog to false."
|
|
95
|
+
);
|
|
96
|
+
const submissions = object(value.submissions, "submissions");
|
|
97
|
+
rejectUnknown(
|
|
98
|
+
submissions,
|
|
99
|
+
["moderation", "maxTitleLength", "maxDescriptionLength"],
|
|
100
|
+
"submissions"
|
|
101
|
+
);
|
|
102
|
+
if (submissions.moderation !== "required")
|
|
103
|
+
throw new Error('submissions.moderation must be "required".');
|
|
104
|
+
const turnstile = object(value.turnstile, "turnstile");
|
|
105
|
+
rejectUnknown(turnstile, ["enabled", "siteKey"], "turnstile");
|
|
106
|
+
if (typeof turnstile.enabled !== "boolean")
|
|
107
|
+
throw new Error("turnstile.enabled must be true or false.");
|
|
108
|
+
const siteKey = turnstile.siteKey === void 0 ? void 0 : text(turnstile.siteKey, "turnstile.siteKey");
|
|
109
|
+
if (turnstile.enabled && !siteKey)
|
|
110
|
+
throw new Error("Turnstile is enabled but turnstile.siteKey is missing.");
|
|
111
|
+
return {
|
|
112
|
+
product: validatedProduct,
|
|
113
|
+
theme: { preset: text(theme.preset, "theme.preset") },
|
|
114
|
+
notion,
|
|
115
|
+
...changelog ? { changelog } : {},
|
|
116
|
+
features: validatedFeatures,
|
|
117
|
+
submissions: {
|
|
118
|
+
moderation: "required",
|
|
119
|
+
maxTitleLength: positiveInteger(
|
|
120
|
+
submissions.maxTitleLength,
|
|
121
|
+
"submissions.maxTitleLength"
|
|
122
|
+
),
|
|
123
|
+
maxDescriptionLength: positiveInteger(
|
|
124
|
+
submissions.maxDescriptionLength,
|
|
125
|
+
"submissions.maxDescriptionLength"
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
turnstile: { enabled: turnstile.enabled, ...siteKey ? { siteKey } : {} }
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function storage(value, keys, label) {
|
|
132
|
+
const input = object(value, label);
|
|
133
|
+
rejectUnknown(input, ["databaseId", "dataSourceId", "properties"], label);
|
|
134
|
+
const properties = object(input.properties, `${label}.properties`);
|
|
135
|
+
rejectUnknown(properties, keys, `${label}.properties`);
|
|
136
|
+
for (const key of keys) text(properties[key], `${label}.properties.${key}`);
|
|
137
|
+
return {
|
|
138
|
+
databaseId: text(input.databaseId, `${label}.databaseId`),
|
|
139
|
+
dataSourceId: text(input.dataSourceId, `${label}.dataSourceId`),
|
|
140
|
+
properties: { ...properties }
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function object(value, label) {
|
|
144
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
145
|
+
throw new Error(`${label} must be an object.`);
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
function text(value, label) {
|
|
149
|
+
if (typeof value !== "string" || !value.trim())
|
|
150
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
151
|
+
return value.trim();
|
|
152
|
+
}
|
|
153
|
+
function positiveInteger(value, label) {
|
|
154
|
+
if (!Number.isInteger(value) || Number(value) <= 0)
|
|
155
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
156
|
+
return Number(value);
|
|
157
|
+
}
|
|
158
|
+
function rejectUnknown(value, allowed, label) {
|
|
159
|
+
const key = Object.keys(value).find(
|
|
160
|
+
(candidate) => !allowed.includes(candidate)
|
|
161
|
+
);
|
|
162
|
+
if (key) throw new Error(`Unknown ${label} key "${key}".`);
|
|
163
|
+
}
|
|
164
|
+
function optionalPublicUrl(value, key) {
|
|
165
|
+
return value[key] === void 0 ? {} : { [key]: publicUrl(value[key], `product.${key}`, false) };
|
|
166
|
+
}
|
|
167
|
+
function publicUrl(value, label, allowRelative) {
|
|
168
|
+
const url = text(value, label);
|
|
169
|
+
if (allowRelative && url.startsWith("/") && !url.startsWith("//") && !url.includes("\\") && !Array.from(url).some((character) => character.charCodeAt(0) <= 32))
|
|
170
|
+
return url;
|
|
171
|
+
try {
|
|
172
|
+
if (new URL(url).protocol === "https:") return url;
|
|
173
|
+
} catch {
|
|
174
|
+
}
|
|
175
|
+
throw new Error(
|
|
176
|
+
`${label} must be ${allowRelative ? "root-relative or " : ""}an HTTPS URL.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/index.ts
|
|
181
|
+
import { basename, join as join2 } from "node:path";
|
|
182
|
+
|
|
183
|
+
// src/template.ts
|
|
184
|
+
import { parse } from "jsonc-parser";
|
|
185
|
+
import {
|
|
186
|
+
cp,
|
|
187
|
+
mkdir,
|
|
188
|
+
readFile,
|
|
189
|
+
readdir,
|
|
190
|
+
rm,
|
|
191
|
+
stat,
|
|
192
|
+
writeFile
|
|
193
|
+
} from "node:fs/promises";
|
|
194
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
195
|
+
import { fileURLToPath } from "node:url";
|
|
196
|
+
var packagedTemplateDirectory = resolve(
|
|
197
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
198
|
+
"../template"
|
|
199
|
+
);
|
|
200
|
+
async function copyPackagedTemplate(destination, packaged = packagedTemplateDirectory) {
|
|
201
|
+
const target = resolve(destination);
|
|
202
|
+
await mkdir(target, { recursive: true });
|
|
203
|
+
if ((await readdir(target)).length)
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Refusing to generate into non-empty destination: ${target}`
|
|
206
|
+
);
|
|
207
|
+
await cp(packaged, target, { recursive: true });
|
|
208
|
+
await renameIgnoreTemplates(target);
|
|
209
|
+
}
|
|
210
|
+
async function renameIgnoreTemplates(target) {
|
|
211
|
+
const { rename } = await import("node:fs/promises");
|
|
212
|
+
for (const name of [".gitignore", ".npmignore", ".prettierignore"])
|
|
213
|
+
await rename(join(target, `${name}.template`), join(target, name));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ../feedback/src/notion-request.ts
|
|
217
|
+
var MAX_ATTEMPTS = 3;
|
|
218
|
+
var BASE_BACKOFF_MILLISECONDS = 250;
|
|
219
|
+
var MAX_BACKOFF_MILLISECONDS = 2e3;
|
|
220
|
+
async function requestNotion(input, init = {}, {
|
|
221
|
+
request,
|
|
222
|
+
retry = {},
|
|
223
|
+
operation
|
|
224
|
+
}) {
|
|
225
|
+
const sleep = retry.sleep ?? defaultSleep;
|
|
226
|
+
const random = retry.random ?? Math.random;
|
|
227
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
228
|
+
const response = await request(input, init);
|
|
229
|
+
if (!isRetryableNotionFailure(response.status, operation)) return response;
|
|
230
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Notion request failed after ${MAX_ATTEMPTS} attempts (${response.status}).`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
await sleep(retryDelayMilliseconds(response, attempt, random));
|
|
236
|
+
}
|
|
237
|
+
throw new Error("Notion request retry loop ended unexpectedly.");
|
|
238
|
+
}
|
|
239
|
+
function isRetryableNotionFailure(status, operation) {
|
|
240
|
+
return status === 429 || status === 529 && operation === "idempotent";
|
|
241
|
+
}
|
|
242
|
+
function retryDelayMilliseconds(response, attempt, random) {
|
|
243
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
244
|
+
if (retryAfter) {
|
|
245
|
+
const seconds = Number(retryAfter);
|
|
246
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
247
|
+
}
|
|
248
|
+
return exponentialDelayMilliseconds(attempt, random);
|
|
249
|
+
}
|
|
250
|
+
function exponentialDelayMilliseconds(attempt, random) {
|
|
251
|
+
const ceiling = Math.min(
|
|
252
|
+
BASE_BACKOFF_MILLISECONDS * 2 ** (attempt - 1),
|
|
253
|
+
MAX_BACKOFF_MILLISECONDS
|
|
254
|
+
);
|
|
255
|
+
return Math.round(ceiling / 2 + random() * (ceiling / 2));
|
|
256
|
+
}
|
|
257
|
+
async function defaultSleep(milliseconds) {
|
|
258
|
+
await new Promise((resolve3) => setTimeout(resolve3, milliseconds));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ../feedback/src/notion-data-source.ts
|
|
262
|
+
var NOTION_API_URL = "https://api.notion.com/v1";
|
|
263
|
+
var NOTION_API_VERSION = "2026-03-11";
|
|
264
|
+
var canonicalProperties = {
|
|
265
|
+
title: { name: "Title", type: "title", schema: { title: {} } },
|
|
266
|
+
slug: { name: "Slug", type: "rich_text", schema: { rich_text: {} } },
|
|
267
|
+
description: {
|
|
268
|
+
name: "Description",
|
|
269
|
+
type: "rich_text",
|
|
270
|
+
schema: { rich_text: {} }
|
|
271
|
+
},
|
|
272
|
+
type: {
|
|
273
|
+
name: "Type",
|
|
274
|
+
type: "select",
|
|
275
|
+
schema: {
|
|
276
|
+
select: {
|
|
277
|
+
options: [
|
|
278
|
+
{ name: "Feature Request" },
|
|
279
|
+
{ name: "Bug Report" },
|
|
280
|
+
{ name: "General Feedback" }
|
|
281
|
+
]
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
status: {
|
|
286
|
+
name: "Status",
|
|
287
|
+
type: "select",
|
|
288
|
+
schema: {
|
|
289
|
+
select: {
|
|
290
|
+
options: [
|
|
291
|
+
{ name: "New" },
|
|
292
|
+
{ name: "Reviewing" },
|
|
293
|
+
{ name: "Planned" },
|
|
294
|
+
{ name: "In Progress" },
|
|
295
|
+
{ name: "Shipped" },
|
|
296
|
+
{ name: "Closed" }
|
|
297
|
+
]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
published: {
|
|
302
|
+
name: "Published",
|
|
303
|
+
type: "checkbox",
|
|
304
|
+
schema: { checkbox: {} }
|
|
305
|
+
},
|
|
306
|
+
voteCount: {
|
|
307
|
+
name: "Vote Count",
|
|
308
|
+
type: "number",
|
|
309
|
+
schema: { number: { format: "number" } }
|
|
310
|
+
},
|
|
311
|
+
teamResponse: {
|
|
312
|
+
name: "Team Response",
|
|
313
|
+
type: "rich_text",
|
|
314
|
+
schema: { rich_text: {} }
|
|
315
|
+
},
|
|
316
|
+
releaseNotesUrl: {
|
|
317
|
+
name: "Release Notes URL",
|
|
318
|
+
type: "url",
|
|
319
|
+
schema: { url: {} }
|
|
320
|
+
},
|
|
321
|
+
submitterName: {
|
|
322
|
+
name: "Submitter Name",
|
|
323
|
+
type: "rich_text",
|
|
324
|
+
schema: { rich_text: {} }
|
|
325
|
+
},
|
|
326
|
+
submitterEmail: {
|
|
327
|
+
name: "Submitter Email",
|
|
328
|
+
type: "email",
|
|
329
|
+
schema: { email: {} }
|
|
330
|
+
},
|
|
331
|
+
source: {
|
|
332
|
+
name: "Source",
|
|
333
|
+
type: "select",
|
|
334
|
+
schema: {
|
|
335
|
+
select: {
|
|
336
|
+
options: [{ name: "Portal" }, { name: "API" }, { name: "Team" }]
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
externalId: {
|
|
341
|
+
name: "External ID",
|
|
342
|
+
type: "rich_text",
|
|
343
|
+
schema: { rich_text: {} }
|
|
344
|
+
},
|
|
345
|
+
editTokenHash: {
|
|
346
|
+
name: "Edit Token Hash",
|
|
347
|
+
type: "rich_text",
|
|
348
|
+
schema: { rich_text: {} }
|
|
349
|
+
},
|
|
350
|
+
createdAt: {
|
|
351
|
+
name: "Created At",
|
|
352
|
+
type: "created_time",
|
|
353
|
+
schema: { created_time: {} }
|
|
354
|
+
},
|
|
355
|
+
updatedAt: {
|
|
356
|
+
name: "Updated At",
|
|
357
|
+
type: "last_edited_time",
|
|
358
|
+
schema: { last_edited_time: {} }
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
var feedbackWorkflowProperties = {
|
|
362
|
+
Owner: { people: {} },
|
|
363
|
+
Priority: {
|
|
364
|
+
select: {
|
|
365
|
+
options: [
|
|
366
|
+
{ name: "Urgent" },
|
|
367
|
+
{ name: "High" },
|
|
368
|
+
{ name: "Medium" },
|
|
369
|
+
{ name: "Low" }
|
|
370
|
+
]
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
"Strategic Theme": { multi_select: { options: [] } },
|
|
374
|
+
"Participant Segment": { multi_select: { options: [] } },
|
|
375
|
+
Impact: {
|
|
376
|
+
select: {
|
|
377
|
+
options: [{ name: "High" }, { name: "Medium" }, { name: "Low" }]
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
Effort: {
|
|
381
|
+
select: {
|
|
382
|
+
options: [{ name: "Small" }, { name: "Medium" }, { name: "Large" }]
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
"Decision Note": { rich_text: {} },
|
|
386
|
+
"Needs Follow-up": { checkbox: {} }
|
|
387
|
+
};
|
|
388
|
+
async function createNotionFeedbackDataSource({
|
|
389
|
+
token,
|
|
390
|
+
parentPageId,
|
|
391
|
+
request = fetch,
|
|
392
|
+
retry
|
|
393
|
+
}) {
|
|
394
|
+
const response = await requestNotion(
|
|
395
|
+
`${NOTION_API_URL}/databases`,
|
|
396
|
+
{
|
|
397
|
+
method: "POST",
|
|
398
|
+
headers: notionHeaders(token),
|
|
399
|
+
body: JSON.stringify({
|
|
400
|
+
parent: { type: "page_id", page_id: parentPageId },
|
|
401
|
+
title: [{ type: "text", text: { content: "Feedback" } }],
|
|
402
|
+
initial_data_source: {
|
|
403
|
+
properties: {
|
|
404
|
+
...Object.fromEntries(
|
|
405
|
+
Object.values(canonicalProperties).map(({ name, schema }) => [
|
|
406
|
+
name,
|
|
407
|
+
schema
|
|
408
|
+
])
|
|
409
|
+
),
|
|
410
|
+
...feedbackWorkflowProperties
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
},
|
|
415
|
+
{ request, retry, operation: "create" }
|
|
416
|
+
);
|
|
417
|
+
const database = await readNotionResponse(response);
|
|
418
|
+
if (typeof database.id !== "string") {
|
|
419
|
+
throw new Error("Notion did not return the created Feedback database.");
|
|
420
|
+
}
|
|
421
|
+
const dataSourceId = firstDataSourceId(database);
|
|
422
|
+
const dataSourceResponse = await requestNotion(
|
|
423
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}`,
|
|
424
|
+
{ method: "GET", headers: notionHeaders(token) },
|
|
425
|
+
{ request, retry, operation: "idempotent" }
|
|
426
|
+
);
|
|
427
|
+
const dataSource = await readNotionResponse(dataSourceResponse);
|
|
428
|
+
return configurationFromCanonicalNames(dataSource, database.id);
|
|
429
|
+
}
|
|
430
|
+
async function createFeedbackWorkflowViews({
|
|
431
|
+
token,
|
|
432
|
+
databaseId,
|
|
433
|
+
dataSourceId,
|
|
434
|
+
propertyIds: propertyIds2,
|
|
435
|
+
request = fetch
|
|
436
|
+
}) {
|
|
437
|
+
const views = [
|
|
438
|
+
{
|
|
439
|
+
name: "Inbox",
|
|
440
|
+
type: "table",
|
|
441
|
+
filter: selectFilter(propertyIds2.status, "New"),
|
|
442
|
+
sorts: [{ property: propertyIds2.createdAt, direction: "descending" }]
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: "Reviewing",
|
|
446
|
+
type: "table",
|
|
447
|
+
filter: selectFilter(propertyIds2.status, "Reviewing"),
|
|
448
|
+
sorts: [{ property: propertyIds2.updatedAt, direction: "descending" }]
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "Prioritization",
|
|
452
|
+
type: "table",
|
|
453
|
+
filter: {
|
|
454
|
+
or: [
|
|
455
|
+
selectFilter(propertyIds2.status, "Reviewing"),
|
|
456
|
+
selectFilter(propertyIds2.status, "Planned")
|
|
457
|
+
]
|
|
458
|
+
},
|
|
459
|
+
sorts: [
|
|
460
|
+
{ property: "Priority", direction: "ascending" },
|
|
461
|
+
{ property: propertyIds2.voteCount, direction: "descending" }
|
|
462
|
+
]
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "Follow-up",
|
|
466
|
+
type: "table",
|
|
467
|
+
filter: { property: "Needs Follow-up", checkbox: { equals: true } }
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: "Roadmap",
|
|
471
|
+
type: "board",
|
|
472
|
+
filter: {
|
|
473
|
+
or: ["Planned", "In Progress", "Shipped"].map(
|
|
474
|
+
(status) => selectFilter(propertyIds2.status, status)
|
|
475
|
+
)
|
|
476
|
+
},
|
|
477
|
+
configuration: {
|
|
478
|
+
type: "board",
|
|
479
|
+
group_by: {
|
|
480
|
+
type: "select",
|
|
481
|
+
property_id: propertyIds2.status,
|
|
482
|
+
group_by: "option",
|
|
483
|
+
sort: { type: "manual" }
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: "Shipped",
|
|
489
|
+
type: "table",
|
|
490
|
+
filter: selectFilter(propertyIds2.status, "Shipped"),
|
|
491
|
+
sorts: [{ property: propertyIds2.updatedAt, direction: "descending" }]
|
|
492
|
+
}
|
|
493
|
+
];
|
|
494
|
+
for (const view of views) {
|
|
495
|
+
const response = await requestNotion(
|
|
496
|
+
`${NOTION_API_URL}/views`,
|
|
497
|
+
{
|
|
498
|
+
method: "POST",
|
|
499
|
+
headers: notionHeaders(token),
|
|
500
|
+
body: JSON.stringify({
|
|
501
|
+
database_id: databaseId,
|
|
502
|
+
data_source_id: dataSourceId,
|
|
503
|
+
...view
|
|
504
|
+
})
|
|
505
|
+
},
|
|
506
|
+
{ request, operation: "create" }
|
|
507
|
+
);
|
|
508
|
+
await readNotionResponse(response);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function selectFilter(property, value) {
|
|
512
|
+
return { property, select: { equals: value } };
|
|
513
|
+
}
|
|
514
|
+
async function validateNotionFeedbackDataSource({
|
|
515
|
+
token,
|
|
516
|
+
dataSourceId,
|
|
517
|
+
propertyIds: propertyIds2,
|
|
518
|
+
request = fetch,
|
|
519
|
+
retry
|
|
520
|
+
}) {
|
|
521
|
+
const response = await requestNotion(
|
|
522
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}`,
|
|
523
|
+
{
|
|
524
|
+
method: "GET",
|
|
525
|
+
headers: notionHeaders(token)
|
|
526
|
+
},
|
|
527
|
+
{ request, retry, operation: "idempotent" }
|
|
528
|
+
);
|
|
529
|
+
const dataSource = await readNotionResponse(response);
|
|
530
|
+
const properties = dataSource.properties;
|
|
531
|
+
if (typeof dataSource.id !== "string" || !isPropertyMap(properties)) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
"Notion returned an invalid Feedback Data Source response."
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
const propertiesById = new Map(
|
|
537
|
+
Object.values(properties).filter(
|
|
538
|
+
(property) => Boolean(property) && typeof property.id === "string"
|
|
539
|
+
).map((property) => [property.id, property])
|
|
540
|
+
);
|
|
541
|
+
const repairs = [];
|
|
542
|
+
for (const [key, { name, type }] of Object.entries(canonicalProperties)) {
|
|
543
|
+
const configuredId = propertyIds2[key];
|
|
544
|
+
const property = propertiesById.get(configuredId);
|
|
545
|
+
if (!property) {
|
|
546
|
+
repairs.push(
|
|
547
|
+
`Restore the "${name}" property with ID "${configuredId}" and type "${type}", then update the configured property ID if Notion assigns a new one.`
|
|
548
|
+
);
|
|
549
|
+
} else if (property.type !== type) {
|
|
550
|
+
repairs.push(
|
|
551
|
+
`Change the property configured as "${name}" (ID "${configuredId}") from "${String(property.type)}" to "${type}", or restore a ${type} property and configure its ID.`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (repairs.length > 0) {
|
|
556
|
+
throw new Error(repairs.join("\n"));
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
databaseId: databaseIdFromDataSource(dataSource),
|
|
560
|
+
dataSourceId,
|
|
561
|
+
propertyIds: { ...propertyIds2 }
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function notionHeaders(token) {
|
|
565
|
+
return {
|
|
566
|
+
Authorization: `Bearer ${token}`,
|
|
567
|
+
"Content-Type": "application/json",
|
|
568
|
+
"Notion-Version": NOTION_API_VERSION
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
async function readNotionResponse(response) {
|
|
572
|
+
const body = await response.json();
|
|
573
|
+
if (!response.ok) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
`Notion rejected the Feedback Data Source request (${response.status}).`
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (!body || typeof body !== "object") {
|
|
579
|
+
throw new Error(
|
|
580
|
+
"Notion returned an invalid Feedback Data Source response."
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return body;
|
|
584
|
+
}
|
|
585
|
+
function firstDataSourceId(database) {
|
|
586
|
+
const dataSources = Reflect.get(database, "data_sources");
|
|
587
|
+
const firstDataSource = Array.isArray(dataSources) ? dataSources[0] : void 0;
|
|
588
|
+
const id = firstDataSource && typeof firstDataSource === "object" ? Reflect.get(firstDataSource, "id") : void 0;
|
|
589
|
+
if (typeof id !== "string") {
|
|
590
|
+
throw new Error("Notion did not return the created Feedback Data Source.");
|
|
591
|
+
}
|
|
592
|
+
return id;
|
|
593
|
+
}
|
|
594
|
+
function configurationFromCanonicalNames(dataSource, databaseId) {
|
|
595
|
+
const properties = dataSource.properties;
|
|
596
|
+
if (typeof dataSource.id !== "string" || !isPropertyMap(properties)) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
"Notion returned an invalid Feedback Data Source response."
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
databaseId,
|
|
603
|
+
dataSourceId: dataSource.id,
|
|
604
|
+
propertyIds: Object.fromEntries(
|
|
605
|
+
Object.entries(canonicalProperties).map(([key, { name }]) => {
|
|
606
|
+
const property = properties[name];
|
|
607
|
+
if (!property || typeof property.id !== "string") {
|
|
608
|
+
throw new Error(
|
|
609
|
+
`Notion did not return the required ${name} property.`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
return [key, property.id];
|
|
613
|
+
})
|
|
614
|
+
)
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function databaseIdFromDataSource(dataSource) {
|
|
618
|
+
const parent = dataSource.parent;
|
|
619
|
+
const databaseId = parent && typeof parent === "object" ? Reflect.get(parent, "database_id") : void 0;
|
|
620
|
+
if (typeof databaseId !== "string") {
|
|
621
|
+
throw new Error(
|
|
622
|
+
"Notion did not return the Feedback Data Source parent database."
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return databaseId;
|
|
626
|
+
}
|
|
627
|
+
function isPropertyMap(value) {
|
|
628
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ../feedback/src/index.ts
|
|
632
|
+
var postCreationQueue = Promise.resolve();
|
|
633
|
+
|
|
634
|
+
// ../changelog/src/notion-changelog.ts
|
|
635
|
+
var NOTION_API_URL2 = "https://api.notion.com/v1";
|
|
636
|
+
var NOTION_API_VERSION2 = "2026-03-11";
|
|
637
|
+
var canonicalProperties2 = {
|
|
638
|
+
title: { name: "Title", type: "title", schema: { title: {} } },
|
|
639
|
+
slug: { name: "Slug", type: "rich_text", schema: { rich_text: {} } },
|
|
640
|
+
date: { name: "Date", type: "date", schema: { date: {} } },
|
|
641
|
+
summary: {
|
|
642
|
+
name: "Summary",
|
|
643
|
+
type: "rich_text",
|
|
644
|
+
schema: { rich_text: {} }
|
|
645
|
+
},
|
|
646
|
+
body: { name: "Body", type: "rich_text", schema: { rich_text: {} } },
|
|
647
|
+
labels: {
|
|
648
|
+
name: "Labels",
|
|
649
|
+
type: "multi_select",
|
|
650
|
+
schema: { multi_select: { options: [] } }
|
|
651
|
+
},
|
|
652
|
+
image: { name: "Image", type: "files", schema: { files: {} } },
|
|
653
|
+
published: {
|
|
654
|
+
name: "Published",
|
|
655
|
+
type: "checkbox",
|
|
656
|
+
schema: { checkbox: {} }
|
|
657
|
+
},
|
|
658
|
+
createdAt: {
|
|
659
|
+
name: "Created At",
|
|
660
|
+
type: "created_time",
|
|
661
|
+
schema: { created_time: {} }
|
|
662
|
+
},
|
|
663
|
+
updatedAt: {
|
|
664
|
+
name: "Updated At",
|
|
665
|
+
type: "last_edited_time",
|
|
666
|
+
schema: { last_edited_time: {} }
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
async function createNotionChangelogDataSource({
|
|
670
|
+
token,
|
|
671
|
+
databaseId,
|
|
672
|
+
request = fetch
|
|
673
|
+
}) {
|
|
674
|
+
const response = await request(`${NOTION_API_URL2}/data_sources`, {
|
|
675
|
+
method: "POST",
|
|
676
|
+
headers: notionHeaders2(token),
|
|
677
|
+
body: JSON.stringify({
|
|
678
|
+
parent: { database_id: databaseId },
|
|
679
|
+
title: [{ type: "text", text: { content: "Changelog" } }],
|
|
680
|
+
properties: Object.fromEntries(
|
|
681
|
+
Object.values(canonicalProperties2).map(({ name, schema }) => [
|
|
682
|
+
name,
|
|
683
|
+
schema
|
|
684
|
+
])
|
|
685
|
+
)
|
|
686
|
+
})
|
|
687
|
+
});
|
|
688
|
+
return configurationFromResponse(
|
|
689
|
+
await readObject(response, "Changelog Data Source"),
|
|
690
|
+
databaseId
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
async function validateNotionChangelogDataSource({
|
|
694
|
+
token,
|
|
695
|
+
dataSourceId,
|
|
696
|
+
propertyIds: propertyIds2,
|
|
697
|
+
request = fetch
|
|
698
|
+
}) {
|
|
699
|
+
const response = await request(
|
|
700
|
+
`${NOTION_API_URL2}/data_sources/${dataSourceId}`,
|
|
701
|
+
{ headers: notionHeaders2(token) }
|
|
702
|
+
);
|
|
703
|
+
const dataSource = await readObject(response, "Changelog Data Source");
|
|
704
|
+
const properties = recordAt(dataSource, "properties");
|
|
705
|
+
const propertiesById = new Map(
|
|
706
|
+
Object.values(properties).flatMap((property) => {
|
|
707
|
+
if (!isRecord(property) || typeof property.id !== "string") return [];
|
|
708
|
+
return [[property.id, property]];
|
|
709
|
+
})
|
|
710
|
+
);
|
|
711
|
+
const repairs = [];
|
|
712
|
+
for (const [key, { name, type }] of Object.entries(canonicalProperties2)) {
|
|
713
|
+
const configuredId = propertyIds2[key];
|
|
714
|
+
const property = propertiesById.get(configuredId);
|
|
715
|
+
if (!property) {
|
|
716
|
+
repairs.push(
|
|
717
|
+
`Restore the "${name}" property with ID "${configuredId}" and type "${type}", then update the configured property ID if Notion assigns a new one.`
|
|
718
|
+
);
|
|
719
|
+
} else if (property.type !== type) {
|
|
720
|
+
repairs.push(
|
|
721
|
+
`Change the property configured as "${name}" (ID "${configuredId}") from "${String(property.type)}" to "${type}", or restore a ${type} property and configure its ID.`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (repairs.length) throw new Error(repairs.join("\n"));
|
|
726
|
+
return {
|
|
727
|
+
databaseId: databaseIdFromResponse(dataSource),
|
|
728
|
+
dataSourceId,
|
|
729
|
+
propertyIds: { ...propertyIds2 }
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function configurationFromResponse(dataSource, databaseId) {
|
|
733
|
+
const properties = recordAt(dataSource, "properties");
|
|
734
|
+
return {
|
|
735
|
+
databaseId,
|
|
736
|
+
dataSourceId: requiredString(dataSource.id),
|
|
737
|
+
propertyIds: Object.fromEntries(
|
|
738
|
+
Object.entries(canonicalProperties2).map(([key, { name }]) => [
|
|
739
|
+
key,
|
|
740
|
+
requiredString(recordAt(properties, name).id)
|
|
741
|
+
])
|
|
742
|
+
)
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
function databaseIdFromResponse(dataSource) {
|
|
746
|
+
return requiredString(recordAt(dataSource, "parent").database_id);
|
|
747
|
+
}
|
|
748
|
+
function notionHeaders2(token) {
|
|
749
|
+
return {
|
|
750
|
+
Authorization: `Bearer ${token}`,
|
|
751
|
+
"Content-Type": "application/json",
|
|
752
|
+
"Notion-Version": NOTION_API_VERSION2
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
async function readObject(response, subject) {
|
|
756
|
+
const body = await response.json();
|
|
757
|
+
if (!response.ok) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`Notion rejected the ${subject} request (${response.status}).`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (!isRecord(body))
|
|
763
|
+
throw new Error(`Notion returned an invalid ${subject} response.`);
|
|
764
|
+
return body;
|
|
765
|
+
}
|
|
766
|
+
function recordAt(value, key) {
|
|
767
|
+
return record(value[key]);
|
|
768
|
+
}
|
|
769
|
+
function record(value) {
|
|
770
|
+
if (!isRecord(value))
|
|
771
|
+
throw new Error("Notion returned an invalid Changelog response.");
|
|
772
|
+
return value;
|
|
773
|
+
}
|
|
774
|
+
function requiredString(value) {
|
|
775
|
+
if (typeof value !== "string")
|
|
776
|
+
throw new Error("Notion returned an invalid Changelog response.");
|
|
777
|
+
return value;
|
|
778
|
+
}
|
|
779
|
+
function isRecord(value) {
|
|
780
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/index.ts
|
|
784
|
+
async function provisionGuidedInstallation({
|
|
785
|
+
destination,
|
|
786
|
+
product,
|
|
787
|
+
themePreset,
|
|
788
|
+
workerName,
|
|
789
|
+
commentProbePageId,
|
|
790
|
+
features,
|
|
791
|
+
feedbackSelection,
|
|
792
|
+
changelogSelection,
|
|
793
|
+
notionToken,
|
|
794
|
+
turnstile,
|
|
795
|
+
preview,
|
|
796
|
+
request = fetch
|
|
797
|
+
}) {
|
|
798
|
+
try {
|
|
799
|
+
if ((await readdir2(destination)).length)
|
|
800
|
+
throw new Error(
|
|
801
|
+
`Refusing to generate into non-empty destination: ${destination}`
|
|
802
|
+
);
|
|
803
|
+
} catch (error) {
|
|
804
|
+
if (error.code !== "ENOENT") throw error;
|
|
805
|
+
}
|
|
806
|
+
if (product.siteUrl && new URL(product.siteUrl).protocol !== "https:")
|
|
807
|
+
throw new Error("product.siteUrl must be an HTTPS URL.");
|
|
808
|
+
const targetName = workerName ?? basename(destination).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
809
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(targetName))
|
|
810
|
+
throw new Error(
|
|
811
|
+
"Worker name must contain 1\u201363 lowercase letters, numbers, or hyphens."
|
|
812
|
+
);
|
|
813
|
+
const approved = await preview(
|
|
814
|
+
feedbackSelection.kind === "create" ? `Create the canonical Feedback Data Source inside Notion page ${feedbackSelection.parentPageId}.` : `Validate existing Feedback Data Source ${feedbackSelection.configuration.dataSourceId} without changing it.`
|
|
815
|
+
);
|
|
816
|
+
if (!approved)
|
|
817
|
+
throw new Error(
|
|
818
|
+
"Feedback setup was not approved. No local files were created."
|
|
819
|
+
);
|
|
820
|
+
const feedback = feedbackSelection.kind === "create" ? await createNotionFeedbackDataSource({
|
|
821
|
+
token: notionToken,
|
|
822
|
+
parentPageId: feedbackSelection.parentPageId,
|
|
823
|
+
request
|
|
824
|
+
}) : await validateNotionFeedbackDataSource({
|
|
825
|
+
token: notionToken,
|
|
826
|
+
...feedbackSelection.configuration,
|
|
827
|
+
request
|
|
828
|
+
});
|
|
829
|
+
if (feedbackSelection.kind === "create") {
|
|
830
|
+
if (!await preview(
|
|
831
|
+
"Create six Notion team views: Inbox, Reviewing, Prioritization, Follow-up, Roadmap, and Shipped."
|
|
832
|
+
))
|
|
833
|
+
throw new Error("Workflow view creation was not approved.");
|
|
834
|
+
await createFeedbackWorkflowViews({
|
|
835
|
+
token: notionToken,
|
|
836
|
+
...feedback,
|
|
837
|
+
request
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
await verifySelectedCommentCapabilities(features, {
|
|
841
|
+
token: notionToken,
|
|
842
|
+
pageId: commentProbePageId ?? (feedbackSelection.kind === "create" ? feedbackSelection.parentPageId : feedback.databaseId),
|
|
843
|
+
request,
|
|
844
|
+
command: "setup"
|
|
845
|
+
});
|
|
846
|
+
const changelog = await configureChangelogStorage({
|
|
847
|
+
features,
|
|
848
|
+
selection: changelogSelection ?? (features.changelog ? { kind: "create", databaseId: feedback.databaseId } : void 0),
|
|
849
|
+
token: notionToken,
|
|
850
|
+
request,
|
|
851
|
+
preview
|
|
852
|
+
});
|
|
853
|
+
await copyPackagedTemplate(destination);
|
|
854
|
+
const wranglerPath = join2(destination, "wrangler.jsonc");
|
|
855
|
+
const wrangler = JSON.parse(await readFile2(wranglerPath, "utf8"));
|
|
856
|
+
await writeFile2(
|
|
857
|
+
wranglerPath,
|
|
858
|
+
JSON.stringify({ ...wrangler, name: targetName }, null, 2) + "\n"
|
|
859
|
+
);
|
|
860
|
+
const apiKey = randomBytes(32).toString("base64url");
|
|
861
|
+
const signingSecret = randomBytes(32).toString("base64url");
|
|
862
|
+
const values = {
|
|
863
|
+
NOTION_TOKEN: notionToken,
|
|
864
|
+
NOTION_FEEDBACK_DATABASE_ID: feedback.databaseId,
|
|
865
|
+
NOTION_FEEDBACK_DATA_SOURCE_ID: feedback.dataSourceId,
|
|
866
|
+
...prefixProperties("NOTION_FEEDBACK", feedback.propertyIds),
|
|
867
|
+
FEEDBAX_API_KEY_HASH: createHash("sha256").update(apiKey).digest("base64url"),
|
|
868
|
+
PARTICIPATION_SIGNING_SECRET: signingSecret,
|
|
869
|
+
FEEDBAX_VOTING_ENABLED: String(features.voting),
|
|
870
|
+
FEEDBAX_COMMENTS_ENABLED: String(features.comments),
|
|
871
|
+
FEEDBAX_CHANGELOG_ENABLED: String(features.changelog),
|
|
872
|
+
...turnstile.siteKey ? { VITE_TURNSTILE_SITE_KEY: turnstile.siteKey } : {},
|
|
873
|
+
...turnstile.secretKey ? { TURNSTILE_SECRET_KEY: turnstile.secretKey } : {},
|
|
874
|
+
...changelog ? {
|
|
875
|
+
NOTION_CHANGELOG_DATABASE_ID: changelog.databaseId,
|
|
876
|
+
NOTION_CHANGELOG_DATA_SOURCE_ID: changelog.dataSourceId,
|
|
877
|
+
...prefixProperties("NOTION_CHANGELOG", changelog.propertyIds)
|
|
878
|
+
} : {}
|
|
879
|
+
};
|
|
880
|
+
await writeFile2(
|
|
881
|
+
join2(destination, ".dev.vars"),
|
|
882
|
+
Object.entries(values).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join("\n") + "\n",
|
|
883
|
+
{ mode: 384 }
|
|
884
|
+
);
|
|
885
|
+
const configuration = defineFeedbax({
|
|
886
|
+
product: { ...product, logo: "/feedbax-mark.svg" },
|
|
887
|
+
theme: { preset: themePreset },
|
|
888
|
+
notion: {
|
|
889
|
+
databaseId: feedback.databaseId,
|
|
890
|
+
dataSourceId: feedback.dataSourceId,
|
|
891
|
+
properties: feedback.propertyIds
|
|
892
|
+
},
|
|
893
|
+
...changelog ? {
|
|
894
|
+
changelog: {
|
|
895
|
+
databaseId: changelog.databaseId,
|
|
896
|
+
dataSourceId: changelog.dataSourceId,
|
|
897
|
+
properties: changelog.propertyIds
|
|
898
|
+
}
|
|
899
|
+
} : {},
|
|
900
|
+
features,
|
|
901
|
+
submissions: {
|
|
902
|
+
moderation: "required",
|
|
903
|
+
maxTitleLength: 160,
|
|
904
|
+
maxDescriptionLength: 5e3
|
|
905
|
+
},
|
|
906
|
+
turnstile: {
|
|
907
|
+
enabled: Boolean(turnstile.siteKey),
|
|
908
|
+
...turnstile.siteKey ? { siteKey: turnstile.siteKey } : {}
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
await writeFile2(
|
|
912
|
+
join2(destination, "src/feedbax.ts"),
|
|
913
|
+
`import { defineFeedbax } from "@feedbax/config";
|
|
914
|
+
|
|
915
|
+
export default defineFeedbax(${JSON.stringify(configuration, null, 2)});
|
|
916
|
+
`
|
|
917
|
+
);
|
|
918
|
+
const probePage = commentProbePageId ?? (feedbackSelection.kind === "create" ? feedbackSelection.parentPageId : feedback.databaseId);
|
|
919
|
+
await writeFile2(
|
|
920
|
+
join2(destination, ".dev.vars"),
|
|
921
|
+
`NOTION_COMMENT_CAPABILITY_PROBE_PAGE_ID=${JSON.stringify(probePage)}
|
|
922
|
+
`,
|
|
923
|
+
{ flag: "a", mode: 384 }
|
|
924
|
+
);
|
|
925
|
+
return { apiKey, feedback, changelog };
|
|
926
|
+
}
|
|
927
|
+
function prefixProperties(prefix, properties) {
|
|
928
|
+
return Object.fromEntries(
|
|
929
|
+
Object.entries(properties).map(([key, value]) => [
|
|
930
|
+
`${prefix}_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}_PROPERTY_ID`,
|
|
931
|
+
value
|
|
932
|
+
])
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
var featureChoices = [
|
|
936
|
+
{ key: "voting", label: "Voting", initialValue: true },
|
|
937
|
+
{ key: "comments", label: "Comments", initialValue: true },
|
|
938
|
+
{ key: "changelog", label: "Changelog", initialValue: true }
|
|
939
|
+
];
|
|
940
|
+
async function collectFeatureSelection(prompter) {
|
|
941
|
+
const selections = [];
|
|
942
|
+
for (const { key, label, initialValue } of featureChoices) {
|
|
943
|
+
selections.push([
|
|
944
|
+
key,
|
|
945
|
+
await prompter.confirm({
|
|
946
|
+
message: `Enable ${label}?`,
|
|
947
|
+
initialValue
|
|
948
|
+
})
|
|
949
|
+
]);
|
|
950
|
+
}
|
|
951
|
+
return Object.fromEntries(selections);
|
|
952
|
+
}
|
|
953
|
+
async function verifySelectedCommentCapabilities(features, {
|
|
954
|
+
token,
|
|
955
|
+
pageId,
|
|
956
|
+
request = fetch,
|
|
957
|
+
command = "setup"
|
|
958
|
+
}) {
|
|
959
|
+
if (!features.comments) return;
|
|
960
|
+
const headers = {
|
|
961
|
+
Authorization: `Bearer ${token}`,
|
|
962
|
+
"Notion-Version": "2026-03-11"
|
|
963
|
+
};
|
|
964
|
+
const readResponse = await request(
|
|
965
|
+
`https://api.notion.com/v1/comments?block_id=${encodeURIComponent(pageId)}&page_size=1`,
|
|
966
|
+
{ headers }
|
|
967
|
+
);
|
|
968
|
+
if (!readResponse.ok) {
|
|
969
|
+
if (readResponse.status === 403)
|
|
970
|
+
assertEnabledCommentCapabilities({
|
|
971
|
+
commentsEnabled: true,
|
|
972
|
+
capabilities: { readComments: false, insertComments: true },
|
|
973
|
+
command
|
|
974
|
+
});
|
|
975
|
+
throw new Error(
|
|
976
|
+
`Notion Comment read check failed (HTTP ${readResponse.status}). Verify a shared page ID and connection access.`
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
if (command === "doctor") {
|
|
980
|
+
assertEnabledCommentCapabilities({
|
|
981
|
+
commentsEnabled: true,
|
|
982
|
+
capabilities: {
|
|
983
|
+
readComments: readResponse.status !== 403,
|
|
984
|
+
// Setup proves Insert comments before enabling the capability. Doctor
|
|
985
|
+
// deliberately avoids a write-method probe and verifies the live read path.
|
|
986
|
+
insertComments: true
|
|
987
|
+
},
|
|
988
|
+
command
|
|
989
|
+
});
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const insertResponse = await request("https://api.notion.com/v1/comments", {
|
|
993
|
+
method: "POST",
|
|
994
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
995
|
+
body: "{}"
|
|
996
|
+
});
|
|
997
|
+
if (insertResponse.status !== 400 && insertResponse.status !== 403)
|
|
998
|
+
throw new Error(
|
|
999
|
+
`Notion Comment insert capability check failed (HTTP ${insertResponse.status}).`
|
|
1000
|
+
);
|
|
1001
|
+
const capabilities = {
|
|
1002
|
+
readComments: readResponse.status !== 403,
|
|
1003
|
+
insertComments: insertResponse.status !== 403
|
|
1004
|
+
};
|
|
1005
|
+
assertEnabledCommentCapabilities({
|
|
1006
|
+
commentsEnabled: true,
|
|
1007
|
+
capabilities,
|
|
1008
|
+
command
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
function assertEnabledCommentCapabilities({
|
|
1012
|
+
commentsEnabled,
|
|
1013
|
+
capabilities,
|
|
1014
|
+
command = "setup"
|
|
1015
|
+
}) {
|
|
1016
|
+
if (!commentsEnabled) return;
|
|
1017
|
+
const missing = [
|
|
1018
|
+
...capabilities.readComments ? [] : ["Read comments"],
|
|
1019
|
+
...capabilities.insertComments ? [] : ["Insert comments"]
|
|
1020
|
+
];
|
|
1021
|
+
if (missing.length === 0) return;
|
|
1022
|
+
const retry = command === "doctor" ? "rerun doctor. No changes were made." : "retry setup.";
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`Enable ${missing.join(" and ")} in the Notion connection settings, then ${retry}`
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
async function configureChangelogStorage({
|
|
1028
|
+
features,
|
|
1029
|
+
selection,
|
|
1030
|
+
token,
|
|
1031
|
+
request = fetch,
|
|
1032
|
+
preview
|
|
1033
|
+
}) {
|
|
1034
|
+
if (!features.changelog) return;
|
|
1035
|
+
if (!selection) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
"Choose an existing compatible Changelog Data Source or create one beside Feedback."
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
if (selection.kind === "existing") {
|
|
1041
|
+
return validateNotionChangelogDataSource({
|
|
1042
|
+
token,
|
|
1043
|
+
dataSourceId: selection.dataSourceId,
|
|
1044
|
+
propertyIds: selection.propertyIds,
|
|
1045
|
+
request
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
const approved = await preview(
|
|
1049
|
+
`Create an empty Changelog Data Source beside Feedback in Feedbax Database ${selection.databaseId}. No sample entries will be published.`
|
|
1050
|
+
);
|
|
1051
|
+
if (!approved) throw new Error("Changelog creation was not approved.");
|
|
1052
|
+
return createNotionChangelogDataSource({
|
|
1053
|
+
token,
|
|
1054
|
+
databaseId: selection.databaseId,
|
|
1055
|
+
request
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// src/cli.ts
|
|
4
1060
|
try {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1061
|
+
const args = process.argv.slice(2);
|
|
1062
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1063
|
+
console.log(
|
|
1064
|
+
"Usage: create-feedbax [directory] [--template-only]\nGuided setup prompts for a destination, product, Notion connection, features, and deployment.\n--template-only copies source without external requests or installing dependencies."
|
|
1065
|
+
);
|
|
1066
|
+
} else if (args.includes("--version")) {
|
|
1067
|
+
console.log(
|
|
1068
|
+
JSON.parse(
|
|
1069
|
+
await readFile3(new URL("../package.json", import.meta.url), "utf8")
|
|
1070
|
+
).version
|
|
1071
|
+
);
|
|
1072
|
+
} else {
|
|
1073
|
+
if (args.some((arg) => arg.startsWith("-") && arg !== "--template-only"))
|
|
1074
|
+
throw new Error("Unknown option. Run create-feedbax --help.");
|
|
1075
|
+
const destination = resolve2(
|
|
1076
|
+
args.find((arg) => !arg.startsWith("-")) ?? await text3("Project directory", "my-feedback")
|
|
1077
|
+
);
|
|
1078
|
+
if (args.includes("--template-only")) {
|
|
1079
|
+
await copyPackagedTemplate(destination);
|
|
1080
|
+
console.log(`Created a flat Feedbax application in ${destination}.`);
|
|
1081
|
+
} else {
|
|
1082
|
+
prompt.intro("Create a Feedbax portal");
|
|
1083
|
+
const product = {
|
|
1084
|
+
name: await text3("Product name", "My product"),
|
|
1085
|
+
description: await text3(
|
|
1086
|
+
"Public description",
|
|
1087
|
+
"Share feedback and follow product progress."
|
|
1088
|
+
),
|
|
1089
|
+
siteUrl: await text3(
|
|
1090
|
+
"Public portal URL (your workers.dev URL or custom domain)",
|
|
1091
|
+
"https://my-feedback.example.workers.dev"
|
|
1092
|
+
)
|
|
1093
|
+
};
|
|
1094
|
+
const workerName = await text3(
|
|
1095
|
+
"Cloudflare Worker name (choose a distinct name for this product)",
|
|
1096
|
+
basename2(destination).toLowerCase().replace(/[^a-z0-9-]/g, "-")
|
|
1097
|
+
);
|
|
1098
|
+
const themePreset = await text3(
|
|
1099
|
+
"shadcn preset code, or lyra-neutral for the included preset",
|
|
1100
|
+
"lyra-neutral"
|
|
1101
|
+
);
|
|
1102
|
+
const notionToken = process.env.NOTION_TOKEN?.trim() || await secret("Notion internal integration token");
|
|
1103
|
+
const existing = await confirm2(
|
|
1104
|
+
"Use an existing compatible Feedback Data Source?",
|
|
1105
|
+
false
|
|
1106
|
+
);
|
|
1107
|
+
const feedbackSelection = existing ? { kind: "existing", configuration: await existingFeedback() } : {
|
|
1108
|
+
kind: "create",
|
|
1109
|
+
parentPageId: await text3(
|
|
1110
|
+
"Notion parent page ID (share it with the integration)"
|
|
1111
|
+
)
|
|
1112
|
+
};
|
|
1113
|
+
const features = await collectFeatureSelection({
|
|
1114
|
+
confirm: ({ message, initialValue }) => confirm2(message, initialValue)
|
|
1115
|
+
});
|
|
1116
|
+
const commentProbePageId = existing && features.comments ? await text3("Shared Notion page ID for checking Comment permissions") : void 0;
|
|
1117
|
+
const changelogSelection = features.changelog && await confirm2(
|
|
1118
|
+
"Use an existing compatible Changelog Data Source?",
|
|
1119
|
+
false
|
|
1120
|
+
) ? {
|
|
1121
|
+
kind: "existing",
|
|
1122
|
+
dataSourceId: await text3("Changelog Data Source ID"),
|
|
1123
|
+
propertyIds: await propertyIds([
|
|
1124
|
+
"title",
|
|
1125
|
+
"slug",
|
|
1126
|
+
"date",
|
|
1127
|
+
"summary",
|
|
1128
|
+
"body",
|
|
1129
|
+
"labels",
|
|
1130
|
+
"image",
|
|
1131
|
+
"published",
|
|
1132
|
+
"createdAt",
|
|
1133
|
+
"updatedAt"
|
|
1134
|
+
])
|
|
1135
|
+
} : void 0;
|
|
1136
|
+
const turnstile = await confirm2(
|
|
1137
|
+
"Enable Cloudflare Turnstile? Recommended for production.",
|
|
1138
|
+
true
|
|
1139
|
+
) ? {
|
|
1140
|
+
siteKey: await text3("Turnstile site key"),
|
|
1141
|
+
secretKey: process.env.TURNSTILE_SECRET_KEY || await secret("Turnstile secret key")
|
|
1142
|
+
} : {};
|
|
1143
|
+
const result = await provisionGuidedInstallation({
|
|
1144
|
+
destination,
|
|
1145
|
+
product,
|
|
1146
|
+
themePreset,
|
|
1147
|
+
workerName,
|
|
1148
|
+
commentProbePageId,
|
|
1149
|
+
features,
|
|
1150
|
+
feedbackSelection,
|
|
1151
|
+
changelogSelection,
|
|
1152
|
+
notionToken,
|
|
1153
|
+
turnstile,
|
|
1154
|
+
preview: (message) => confirm2(message, false)
|
|
1155
|
+
});
|
|
1156
|
+
console.log(
|
|
1157
|
+
`Created ${destination}.
|
|
1158
|
+
Trusted API key \u2014 copy it now; plaintext is not stored:
|
|
1159
|
+
${result.apiKey}`
|
|
1160
|
+
);
|
|
1161
|
+
await runIn(destination, "pnpm", ["install"]);
|
|
1162
|
+
if (themePreset !== "lyra-neutral")
|
|
1163
|
+
await runIn(destination, "pnpm", [
|
|
1164
|
+
"dlx",
|
|
1165
|
+
"shadcn@latest",
|
|
1166
|
+
"init",
|
|
1167
|
+
"--preset",
|
|
1168
|
+
themePreset,
|
|
1169
|
+
"--yes",
|
|
1170
|
+
"--force"
|
|
1171
|
+
]);
|
|
1172
|
+
await runIn(destination, "pnpm", [
|
|
1173
|
+
"exec",
|
|
1174
|
+
"prettier",
|
|
1175
|
+
"--write",
|
|
1176
|
+
"src/feedbax.ts"
|
|
1177
|
+
]);
|
|
1178
|
+
await runIn(destination, "pnpm", ["exec", "feedbax", "doctor"]);
|
|
1179
|
+
if (await confirm2(
|
|
1180
|
+
"Authenticate with Cloudflare and deploy this portal?",
|
|
1181
|
+
false
|
|
1182
|
+
)) {
|
|
1183
|
+
await runIn(destination, "pnpm", ["exec", "wrangler", "login"]);
|
|
1184
|
+
await runIn(destination, "pnpm", ["exec", "feedbax", "deploy"]);
|
|
1185
|
+
}
|
|
1186
|
+
prompt.outro(`Your portal source is ready in ${destination}.`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
console.error(
|
|
1191
|
+
error instanceof Error ? error.message : "Feedbax setup failed."
|
|
1192
|
+
);
|
|
1193
|
+
process.exitCode = 10;
|
|
1194
|
+
}
|
|
1195
|
+
function checked(value) {
|
|
1196
|
+
if (prompt.isCancel(value)) throw new Error("Setup cancelled.");
|
|
1197
|
+
return value;
|
|
1198
|
+
}
|
|
1199
|
+
async function text3(message, defaultValue) {
|
|
1200
|
+
return checked(
|
|
1201
|
+
await prompt.text({
|
|
1202
|
+
message,
|
|
1203
|
+
defaultValue,
|
|
1204
|
+
placeholder: defaultValue,
|
|
1205
|
+
validate: (value) => value?.trim() || defaultValue ? void 0 : "A value is required."
|
|
1206
|
+
})
|
|
1207
|
+
).trim();
|
|
1208
|
+
}
|
|
1209
|
+
async function secret(message) {
|
|
1210
|
+
return checked(
|
|
1211
|
+
await prompt.password({
|
|
1212
|
+
message,
|
|
1213
|
+
validate: (value) => value?.trim() ? void 0 : "A value is required."
|
|
1214
|
+
})
|
|
1215
|
+
).trim();
|
|
1216
|
+
}
|
|
1217
|
+
async function confirm2(message, initialValue) {
|
|
1218
|
+
return checked(await prompt.confirm({ message, initialValue }));
|
|
1219
|
+
}
|
|
1220
|
+
async function propertyIds(keys) {
|
|
1221
|
+
const entries = [];
|
|
1222
|
+
for (const key of keys)
|
|
1223
|
+
entries.push([key, await text3(`Stable property ID: ${key}`)]);
|
|
1224
|
+
return Object.fromEntries(entries);
|
|
1225
|
+
}
|
|
1226
|
+
async function existingFeedback() {
|
|
1227
|
+
return {
|
|
1228
|
+
databaseId: await text3("Feedback Database ID"),
|
|
1229
|
+
dataSourceId: await text3("Feedback Data Source ID"),
|
|
1230
|
+
propertyIds: await propertyIds([
|
|
1231
|
+
"title",
|
|
1232
|
+
"slug",
|
|
1233
|
+
"description",
|
|
1234
|
+
"type",
|
|
1235
|
+
"status",
|
|
1236
|
+
"published",
|
|
1237
|
+
"voteCount",
|
|
1238
|
+
"teamResponse",
|
|
1239
|
+
"releaseNotesUrl",
|
|
1240
|
+
"submitterName",
|
|
1241
|
+
"submitterEmail",
|
|
1242
|
+
"source",
|
|
1243
|
+
"externalId",
|
|
1244
|
+
"editTokenHash",
|
|
1245
|
+
"createdAt",
|
|
1246
|
+
"updatedAt"
|
|
1247
|
+
])
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
function runIn(cwd, command, args) {
|
|
1251
|
+
return new Promise((resolve3, reject) => {
|
|
1252
|
+
const child = spawn(command, args, { cwd, stdio: "inherit" });
|
|
1253
|
+
child.on("error", reject);
|
|
1254
|
+
child.on(
|
|
1255
|
+
"exit",
|
|
1256
|
+
(code) => code === 0 ? resolve3() : reject(
|
|
1257
|
+
new Error(
|
|
1258
|
+
`${command} failed with exit code ${code}. The generated source has been preserved.`
|
|
1259
|
+
)
|
|
1260
|
+
)
|
|
1261
|
+
);
|
|
1262
|
+
});
|
|
1263
|
+
}
|