@rocapine/community 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/README.md +118 -0
- package/lib/commands/adopt.d.ts +38 -0
- package/lib/commands/adopt.js +105 -0
- package/lib/commands/init.d.ts +29 -0
- package/lib/commands/init.js +152 -0
- package/lib/commands/upgrade.d.ts +33 -0
- package/lib/commands/upgrade.js +280 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +123 -0
- package/lib/install-shared.d.ts +72 -0
- package/lib/install-shared.js +256 -0
- package/lib/manifest.d.ts +53 -0
- package/lib/manifest.js +74 -0
- package/lib/substitute.d.ts +15 -0
- package/lib/substitute.js +25 -0
- package/package.json +32 -0
- package/templates/functions/_shared/client.ts +20 -0
- package/templates/functions/_shared/config.ts +23 -0
- package/templates/functions/_shared/moderation.ts +102 -0
- package/templates/functions/_shared/push.ts +33 -0
- package/templates/functions/_shared/slack.ts +12 -0
- package/templates/functions/broadcast-post/index.ts +73 -0
- package/templates/functions/daily-moderation/index.ts +175 -0
- package/templates/functions/moderate-one/index.ts +62 -0
- package/templates/functions/notify-comment/index.ts +63 -0
- package/templates/functions/notify-like/index.ts +103 -0
- package/templates/functions/notify-reaction/index.ts +186 -0
- package/templates/functions/report-to-slack/index.ts +37 -0
- package/templates/functions/update-profile/index.ts +131 -0
- package/templates/migrations/core/001_tables.sql +177 -0
- package/templates/migrations/core/002_rls.sql +154 -0
- package/templates/migrations/core/003_moderation.sql +68 -0
- package/templates/migrations/core/004_dashboard.sql +102 -0
- package/templates/migrations/core/005_avatars.sql +19 -0
- package/templates/migrations/core/006_meta.sql +11 -0
- package/templates/migrations/inbox/001_inbox.sql +237 -0
- package/templates/migrations/polls/001_polls.sql +120 -0
- package/templates/migrations/push/001_push.sql +42 -0
- package/templates/migrations/push/002_triggers.sql +89 -0
- package/templates/migrations/reaction/001_reactions.sql +139 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MODULE_ORDER = void 0;
|
|
37
|
+
exports.functionsForModules = functionsForModules;
|
|
38
|
+
exports.resolveModules = resolveModules;
|
|
39
|
+
exports.templateBaseName = templateBaseName;
|
|
40
|
+
exports.migrationDestFilename = migrationDestFilename;
|
|
41
|
+
exports.parseMigrationDestFilename = parseMigrationDestFilename;
|
|
42
|
+
exports.defaultTemplatesDir = defaultTemplatesDir;
|
|
43
|
+
exports.resolveTemplateRoots = resolveTemplateRoots;
|
|
44
|
+
exports.readOwnPackageVersion = readOwnPackageVersion;
|
|
45
|
+
exports.deriveProjectUrlFromConfig = deriveProjectUrlFromConfig;
|
|
46
|
+
exports.resolveProjectCredentials = resolveProjectCredentials;
|
|
47
|
+
exports.formatTimestamp = formatTimestamp;
|
|
48
|
+
exports.addSeconds = addSeconds;
|
|
49
|
+
exports.listFilesRecursive = listFilesRecursive;
|
|
50
|
+
exports.toRelativePosix = toRelativePosix;
|
|
51
|
+
exports.resolveContainedDir = resolveContainedDir;
|
|
52
|
+
const fs = __importStar(require("node:fs"));
|
|
53
|
+
const path = __importStar(require("node:path"));
|
|
54
|
+
const promises_1 = require("node:readline/promises");
|
|
55
|
+
/**
|
|
56
|
+
* Ruled install order (controller ruling, task 18): core -> push -> polls ->
|
|
57
|
+
* reaction -> inbox. Reaction MUST land before inbox — inbox/001's reaction
|
|
58
|
+
* trigger is guarded with `to_regclass('public.post_reactions')` at install
|
|
59
|
+
* time and is never retroactively wired if the reaction module shows up
|
|
60
|
+
* later, so `reaction` needs to already exist when `inbox` runs.
|
|
61
|
+
*
|
|
62
|
+
* Shared between `init` (task 18) and `upgrade`/`adopt` (task 19) — the
|
|
63
|
+
* single source of truth for module names, order, and the function sets
|
|
64
|
+
* each module pulls in.
|
|
65
|
+
*/
|
|
66
|
+
exports.MODULE_ORDER = ["core", "push", "polls", "reaction", "inbox"];
|
|
67
|
+
const ALWAYS_FUNCTIONS = [
|
|
68
|
+
"moderate-one",
|
|
69
|
+
"daily-moderation",
|
|
70
|
+
"update-profile",
|
|
71
|
+
"report-to-slack",
|
|
72
|
+
"_shared",
|
|
73
|
+
];
|
|
74
|
+
const PUSH_FUNCTIONS = ["notify-like", "notify-comment", "broadcast-post"];
|
|
75
|
+
const REACTION_FUNCTIONS = ["notify-reaction"];
|
|
76
|
+
function functionsForModules(modules) {
|
|
77
|
+
const set = new Set(ALWAYS_FUNCTIONS);
|
|
78
|
+
if (modules.includes("push"))
|
|
79
|
+
for (const f of PUSH_FUNCTIONS)
|
|
80
|
+
set.add(f);
|
|
81
|
+
if (modules.includes("reaction"))
|
|
82
|
+
for (const f of REACTION_FUNCTIONS)
|
|
83
|
+
set.add(f);
|
|
84
|
+
return [...set];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Validates and canonicalizes a requested module list: rejects unknown
|
|
88
|
+
* names, always implies `core`, orders the result per MODULE_ORDER, and
|
|
89
|
+
* warns (non-fatally) when `inbox` is picked without `reaction`.
|
|
90
|
+
*/
|
|
91
|
+
function resolveModules(requested, onWarn) {
|
|
92
|
+
const source = requested && requested.length > 0 ? requested : [...exports.MODULE_ORDER];
|
|
93
|
+
const requestedSet = new Set(source.map((m) => m.trim()).filter(Boolean));
|
|
94
|
+
for (const m of requestedSet) {
|
|
95
|
+
if (!exports.MODULE_ORDER.includes(m)) {
|
|
96
|
+
throw new Error(`community-sdk: unknown module "${m}" (expected one of ${exports.MODULE_ORDER.join(", ")})`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// core is always required/implied.
|
|
100
|
+
requestedSet.add("core");
|
|
101
|
+
const canonicalModules = exports.MODULE_ORDER.filter((m) => requestedSet.has(m));
|
|
102
|
+
if (canonicalModules.includes("inbox") && !canonicalModules.includes("reaction")) {
|
|
103
|
+
onWarn("community-sdk: module 'inbox' selected without 'reaction' — reaction notifications won't appear in the inbox until the reaction module is also installed.");
|
|
104
|
+
}
|
|
105
|
+
return canonicalModules;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Strips a template migration's leading numeric ordering prefix and `.sql`
|
|
109
|
+
* extension, e.g. "001_tables.sql" -> "tables". This is both the middle
|
|
110
|
+
* segment of the timestamped destination filename init/upgrade write
|
|
111
|
+
* (`<ts>_community_<module>_<name>.sql`) AND — paired with the module name —
|
|
112
|
+
* the stable identity `upgrade` diffs against (see manifest.ts
|
|
113
|
+
* `migrationTemplateId`).
|
|
114
|
+
*/
|
|
115
|
+
function templateBaseName(templateFilename) {
|
|
116
|
+
return templateFilename.replace(/\.sql$/, "").replace(/^\d+_/, "");
|
|
117
|
+
}
|
|
118
|
+
function migrationDestFilename(timestamp, moduleName, name) {
|
|
119
|
+
return `${timestamp}_community_${moduleName}_${name}.sql`;
|
|
120
|
+
}
|
|
121
|
+
const MIGRATION_DEST_RE = /^\d{14}_community_([a-z]+)_(.+)\.sql$/;
|
|
122
|
+
/**
|
|
123
|
+
* Parses a filename previously written by init/upgrade
|
|
124
|
+
* (`<ts>_community_<module>_<name>.sql`) back into its module + base name.
|
|
125
|
+
* Returns null for anything else (e.g. a function file, or a migration a
|
|
126
|
+
* user hand-wrote outside the SDK) — used by `upgrade` as a fallback when
|
|
127
|
+
* reconstructing template identity from a manifest that predates the
|
|
128
|
+
* `installedTemplates` field.
|
|
129
|
+
*/
|
|
130
|
+
function parseMigrationDestFilename(filename) {
|
|
131
|
+
const match = MIGRATION_DEST_RE.exec(filename);
|
|
132
|
+
if (!match)
|
|
133
|
+
return null;
|
|
134
|
+
return { moduleName: match[1], name: match[2] };
|
|
135
|
+
}
|
|
136
|
+
function defaultTemplatesDir() {
|
|
137
|
+
// This file and src/commands/*.ts both sit two levels below the package
|
|
138
|
+
// root (packages/cli), so this resolves identically pre- and post-build.
|
|
139
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
140
|
+
const packaged = path.join(packageRoot, "templates");
|
|
141
|
+
// STALENESS TRAP: once packages/cli/templates/ exists on disk (written by
|
|
142
|
+
// any prior `npm run build`, whose `prebuild` hook runs
|
|
143
|
+
// scripts/copy-templates.mjs), it wins over the live repo-root supabase/
|
|
144
|
+
// tree below — even during local dev. Editing supabase/**/*.sql or
|
|
145
|
+
// supabase/functions/** and then running `node lib/index.js init` (or a
|
|
146
|
+
// test that doesn't pass an explicit `templatesDir`) will silently use the
|
|
147
|
+
// last-built snapshot, not your edits. Rerun `npm run build` (or `node
|
|
148
|
+
// scripts/copy-templates.mjs` directly) to refresh packages/cli/templates/
|
|
149
|
+
// after touching supabase/.
|
|
150
|
+
if (fs.existsSync(packaged))
|
|
151
|
+
return packaged;
|
|
152
|
+
// Dev/test fallback: the monorepo's own supabase/ source of truth, used
|
|
153
|
+
// only while packages/cli/templates/ has never been built.
|
|
154
|
+
const repoRoot = path.resolve(packageRoot, "..", "..");
|
|
155
|
+
return path.join(repoRoot, "supabase");
|
|
156
|
+
}
|
|
157
|
+
/** Asserts `templatesDir` looks like a real template tree and returns its two roots. */
|
|
158
|
+
function resolveTemplateRoots(templatesDir) {
|
|
159
|
+
const migrationsSrcRoot = path.join(templatesDir, "migrations");
|
|
160
|
+
const functionsSrcRoot = path.join(templatesDir, "functions");
|
|
161
|
+
if (!fs.existsSync(migrationsSrcRoot) || !fs.existsSync(functionsSrcRoot)) {
|
|
162
|
+
throw new Error(`community-sdk: templates not found at ${templatesDir} (expected migrations/ and functions/ subdirectories)`);
|
|
163
|
+
}
|
|
164
|
+
return { migrationsSrcRoot, functionsSrcRoot };
|
|
165
|
+
}
|
|
166
|
+
function readOwnPackageVersion() {
|
|
167
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
168
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
169
|
+
return pkg.version;
|
|
170
|
+
}
|
|
171
|
+
function deriveProjectUrlFromConfig(dir) {
|
|
172
|
+
const configPath = path.join(dir, "config.toml");
|
|
173
|
+
if (!fs.existsSync(configPath))
|
|
174
|
+
return null;
|
|
175
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
176
|
+
const match = raw.match(/^\s*project_id\s*=\s*"([^"]+)"/m);
|
|
177
|
+
if (!match)
|
|
178
|
+
return null;
|
|
179
|
+
return `https://${match[1]}.supabase.co`;
|
|
180
|
+
}
|
|
181
|
+
function defaultPrompt(question) {
|
|
182
|
+
const rl = (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout });
|
|
183
|
+
return rl.question(question).then((answer) => {
|
|
184
|
+
rl.close();
|
|
185
|
+
return answer;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Resolves the Supabase project URL + anon key from (in order) explicit
|
|
190
|
+
* flags, `<dir>/config.toml` (URL only), or an interactive prompt — failing
|
|
191
|
+
* loudly instead of hanging when stdin is not a TTY and no `prompt`
|
|
192
|
+
* override was injected. Shared by `init` and `upgrade`, both of which only
|
|
193
|
+
* need this when a placeholder-bearing file is about to be written.
|
|
194
|
+
*/
|
|
195
|
+
async function resolveProjectCredentials(options) {
|
|
196
|
+
const usingDefaultPrompt = !options.prompt;
|
|
197
|
+
const prompt = options.prompt ?? defaultPrompt;
|
|
198
|
+
let projectUrl = options.projectUrl;
|
|
199
|
+
let anonKey = options.anonKey;
|
|
200
|
+
if (!projectUrl) {
|
|
201
|
+
projectUrl = deriveProjectUrlFromConfig(options.dir) ?? undefined;
|
|
202
|
+
}
|
|
203
|
+
const stillMissingFlags = [];
|
|
204
|
+
if (!projectUrl)
|
|
205
|
+
stillMissingFlags.push("--project-url");
|
|
206
|
+
if (!anonKey)
|
|
207
|
+
stillMissingFlags.push("--anon-key");
|
|
208
|
+
if (stillMissingFlags.length > 0 && usingDefaultPrompt && !process.stdin.isTTY) {
|
|
209
|
+
throw new Error(`community-sdk: missing required flag(s) ${stillMissingFlags.join(", ")}. stdin is not a TTY, so this command cannot prompt for ${stillMissingFlags.length > 1 ? "them" : "it"} — pass ${stillMissingFlags.length > 1 ? "them" : "it"} explicitly.`);
|
|
210
|
+
}
|
|
211
|
+
if (!projectUrl) {
|
|
212
|
+
projectUrl = (await prompt("Supabase project URL (https://<ref>.supabase.co): ")).trim();
|
|
213
|
+
}
|
|
214
|
+
if (!anonKey) {
|
|
215
|
+
anonKey = (await prompt("Supabase anon key: ")).trim();
|
|
216
|
+
}
|
|
217
|
+
return { projectUrl, anonKey };
|
|
218
|
+
}
|
|
219
|
+
function formatTimestamp(date) {
|
|
220
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
221
|
+
return (String(date.getUTCFullYear()) +
|
|
222
|
+
pad(date.getUTCMonth() + 1) +
|
|
223
|
+
pad(date.getUTCDate()) +
|
|
224
|
+
pad(date.getUTCHours()) +
|
|
225
|
+
pad(date.getUTCMinutes()) +
|
|
226
|
+
pad(date.getUTCSeconds()));
|
|
227
|
+
}
|
|
228
|
+
function addSeconds(date, seconds) {
|
|
229
|
+
return new Date(date.getTime() + seconds * 1000);
|
|
230
|
+
}
|
|
231
|
+
function listFilesRecursive(dir) {
|
|
232
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
233
|
+
const files = [];
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
const full = path.join(dir, entry.name);
|
|
236
|
+
if (entry.isDirectory()) {
|
|
237
|
+
files.push(...listFilesRecursive(full));
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
files.push(full);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return files;
|
|
244
|
+
}
|
|
245
|
+
function toRelativePosix(base, target) {
|
|
246
|
+
return path.relative(base, target).split(path.sep).join("/");
|
|
247
|
+
}
|
|
248
|
+
/** Resolves `dirRel` against `cwd`, throwing if it would escape the project root. */
|
|
249
|
+
function resolveContainedDir(cwd, dirRel) {
|
|
250
|
+
const cwdResolved = path.resolve(cwd);
|
|
251
|
+
const dir = path.resolve(cwdResolved, dirRel);
|
|
252
|
+
if (dir !== cwdResolved && !dir.startsWith(cwdResolved + path.sep)) {
|
|
253
|
+
throw new Error(`community-sdk: --dir must resolve to a directory inside the project root, got "${dirRel}" -> ${dir}`);
|
|
254
|
+
}
|
|
255
|
+
return dir;
|
|
256
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* community-sdk.json — written at the app repo root by `community init`
|
|
3
|
+
* (and updated by `community upgrade` / `community adopt`). Tracks what was
|
|
4
|
+
* installed so a second `init`/`adopt` can refuse to run and `upgrade`
|
|
5
|
+
* knows what it is diffing against.
|
|
6
|
+
*/
|
|
7
|
+
export type Manifest = {
|
|
8
|
+
schemaVersion: number;
|
|
9
|
+
sdkVersion: string;
|
|
10
|
+
modules: string[];
|
|
11
|
+
installedFiles: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Identity of every migration template already installed, one entry per
|
|
14
|
+
* template as `"<module>/<templateBaseName>"` (e.g. "core/tables",
|
|
15
|
+
* matching install-shared.ts `templateBaseName("001_tables.sql")`).
|
|
16
|
+
*
|
|
17
|
+
* This — not `installedFiles` — is what `upgrade` diffs the shipped
|
|
18
|
+
* template tree against, because installedFiles holds the *timestamped
|
|
19
|
+
* copy* name (`<ts>_community_core_tables.sql`) and a fresh timestamp is
|
|
20
|
+
* minted on every init/upgrade run, so those names can never be
|
|
21
|
+
* meaningfully compared across runs. The template's numeric ordering
|
|
22
|
+
* prefix (the "001" in "001_tables.sql") is intentionally dropped from
|
|
23
|
+
* the identity too: it only controls copy order within a module, and
|
|
24
|
+
* once a template has shipped, a real change to it must arrive as a new,
|
|
25
|
+
* separately-named template (migrations are append-only) rather than an
|
|
26
|
+
* edit — so base-name identity is exactly the granularity `upgrade`
|
|
27
|
+
* needs.
|
|
28
|
+
*
|
|
29
|
+
* Optional for backward compatibility: absent on manifests written by
|
|
30
|
+
* schemaVersion 1 `init` before this field existed (Task 18).
|
|
31
|
+
* `readManifest` still parses those manifests fine; `upgrade` falls back
|
|
32
|
+
* to reconstructing this set from `installedFiles`' timestamped filenames
|
|
33
|
+
* via `parseMigrationDestFilename` in that case (see install-shared.ts) —
|
|
34
|
+
* which recovers the exact same `<module>/<templateBaseName>` shape,
|
|
35
|
+
* since the prefix was already stripped before those filenames were
|
|
36
|
+
* written.
|
|
37
|
+
*/
|
|
38
|
+
installedTemplates?: string[];
|
|
39
|
+
};
|
|
40
|
+
export declare const MANIFEST_FILENAME = "community-sdk.json";
|
|
41
|
+
/** The only schemaVersion this CLI currently writes. */
|
|
42
|
+
export declare const CURRENT_SCHEMA_VERSION = 1;
|
|
43
|
+
/**
|
|
44
|
+
* Stable identity for a migration template, used to populate/diff
|
|
45
|
+
* `Manifest.installedTemplates`. `templateBaseName` is the template's
|
|
46
|
+
* filename with its numeric prefix and `.sql` extension stripped (see
|
|
47
|
+
* install-shared.ts) — callers pass that in, not the raw template filename,
|
|
48
|
+
* so this function has no filesystem-naming knowledge of its own.
|
|
49
|
+
*/
|
|
50
|
+
export declare function migrationTemplateId(moduleName: string, templateBaseName: string): string;
|
|
51
|
+
export declare function manifestPath(dir: string): string;
|
|
52
|
+
export declare function readManifest(dir: string): Manifest | null;
|
|
53
|
+
export declare function writeManifest(dir: string, manifest: Manifest): void;
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CURRENT_SCHEMA_VERSION = exports.MANIFEST_FILENAME = void 0;
|
|
37
|
+
exports.migrationTemplateId = migrationTemplateId;
|
|
38
|
+
exports.manifestPath = manifestPath;
|
|
39
|
+
exports.readManifest = readManifest;
|
|
40
|
+
exports.writeManifest = writeManifest;
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
43
|
+
exports.MANIFEST_FILENAME = "community-sdk.json";
|
|
44
|
+
/** The only schemaVersion this CLI currently writes. */
|
|
45
|
+
exports.CURRENT_SCHEMA_VERSION = 1;
|
|
46
|
+
/**
|
|
47
|
+
* Stable identity for a migration template, used to populate/diff
|
|
48
|
+
* `Manifest.installedTemplates`. `templateBaseName` is the template's
|
|
49
|
+
* filename with its numeric prefix and `.sql` extension stripped (see
|
|
50
|
+
* install-shared.ts) — callers pass that in, not the raw template filename,
|
|
51
|
+
* so this function has no filesystem-naming knowledge of its own.
|
|
52
|
+
*/
|
|
53
|
+
function migrationTemplateId(moduleName, templateBaseName) {
|
|
54
|
+
return `${moduleName}/${templateBaseName}`;
|
|
55
|
+
}
|
|
56
|
+
function manifestPath(dir) {
|
|
57
|
+
return path.join(dir, exports.MANIFEST_FILENAME);
|
|
58
|
+
}
|
|
59
|
+
function readManifest(dir) {
|
|
60
|
+
const file = manifestPath(dir);
|
|
61
|
+
if (!fs.existsSync(file))
|
|
62
|
+
return null;
|
|
63
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new Error(`community-sdk: ${file} is corrupt (invalid JSON)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function writeManifest(dir, manifest) {
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
fs.writeFileSync(manifestPath(dir), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replaces the `__SUPABASE_PROJECT_URL__` / `__SUPABASE_ANON_KEY__` placeholders
|
|
3
|
+
* baked into the shipped migration SQL (see core/003, push/002, reaction/001)
|
|
4
|
+
* with the values for the app's own Supabase project.
|
|
5
|
+
*
|
|
6
|
+
* Throws if any `__SUPABASE...` token remains after substitution — a guard
|
|
7
|
+
* against a third, unhandled placeholder silently reaching the app's
|
|
8
|
+
* migrations directory (the migration's own DO block re-checks this at
|
|
9
|
+
* `db push` time, but failing fast here gives a clearer error before any
|
|
10
|
+
* SQL is written to disk).
|
|
11
|
+
*/
|
|
12
|
+
export declare function substitutePlaceholders(sql: string, values: {
|
|
13
|
+
projectUrl: string;
|
|
14
|
+
anonKey: string;
|
|
15
|
+
}): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.substitutePlaceholders = substitutePlaceholders;
|
|
4
|
+
/**
|
|
5
|
+
* Replaces the `__SUPABASE_PROJECT_URL__` / `__SUPABASE_ANON_KEY__` placeholders
|
|
6
|
+
* baked into the shipped migration SQL (see core/003, push/002, reaction/001)
|
|
7
|
+
* with the values for the app's own Supabase project.
|
|
8
|
+
*
|
|
9
|
+
* Throws if any `__SUPABASE...` token remains after substitution — a guard
|
|
10
|
+
* against a third, unhandled placeholder silently reaching the app's
|
|
11
|
+
* migrations directory (the migration's own DO block re-checks this at
|
|
12
|
+
* `db push` time, but failing fast here gives a clearer error before any
|
|
13
|
+
* SQL is written to disk).
|
|
14
|
+
*/
|
|
15
|
+
function substitutePlaceholders(sql, values) {
|
|
16
|
+
const result = sql
|
|
17
|
+
.split("__SUPABASE_PROJECT_URL__")
|
|
18
|
+
.join(values.projectUrl)
|
|
19
|
+
.split("__SUPABASE_ANON_KEY__")
|
|
20
|
+
.join(values.anonKey);
|
|
21
|
+
if (result.includes("__SUPABASE")) {
|
|
22
|
+
throw new Error("community-sdk: unsubstituted __SUPABASE placeholder remains after substitution");
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rocapine/community",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI to install, upgrade and adopt the Rocapine Community SDK's Supabase backend (migrations, Edge Functions) into a project.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Rocapine/community-sdk"
|
|
9
|
+
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"types": "lib/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"templates",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"community": "lib/index.js"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"prebuild": "node scripts/copy-templates.mjs",
|
|
23
|
+
"build": "tsc -p tsconfig.json --outDir lib --noEmit false",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"commander": "^12.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.10.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Shared Supabase admin client + JSON response helper.
|
|
2
|
+
|
|
3
|
+
import { createClient } from "jsr:@supabase/supabase-js@2";
|
|
4
|
+
|
|
5
|
+
export const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
|
6
|
+
|
|
7
|
+
// Service-role client: bypasses RLS. Every function does privileged work
|
|
8
|
+
// through this; callers only ever pass the public anon JWT (verify_jwt).
|
|
9
|
+
export function adminClient() {
|
|
10
|
+
return createClient(SUPABASE_URL, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The client consumes responses via supabase.functions.invoke, which only
|
|
14
|
+
// parses the body as JSON when the Content-Type says so. Without this header
|
|
15
|
+
// invoke returns a raw string and verdict.status is undefined.
|
|
16
|
+
export const json = (body: unknown, status = 200) =>
|
|
17
|
+
new Response(JSON.stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Per-app copy used inside notifications, injected as function secrets so the
|
|
2
|
+
// same function code deploys unchanged to every app:
|
|
3
|
+
//
|
|
4
|
+
// supabase secrets set COMMUNITY_FALLBACK_NAME="A member"
|
|
5
|
+
// supabase secrets set COMMUNITY_APP_NAME="My App"
|
|
6
|
+
// supabase secrets set COMMUNITY_REACTION_PUSH_TEXT="{name} sent you support"
|
|
7
|
+
//
|
|
8
|
+
// Defaults are deliberately neutral.
|
|
9
|
+
|
|
10
|
+
// Display name used when the acting user has no username yet
|
|
11
|
+
// ("<name>: ..." in comment pushes, "<name> liked your post").
|
|
12
|
+
export const FALLBACK_ACTOR_NAME = Deno.env.get("COMMUNITY_FALLBACK_NAME") ?? "Someone";
|
|
13
|
+
|
|
14
|
+
// Push title fallback for official broadcast posts whose author has no
|
|
15
|
+
// username (normally the official account's display name).
|
|
16
|
+
export const BROADCAST_FALLBACK_TITLE = Deno.env.get("COMMUNITY_APP_NAME") ?? "Community";
|
|
17
|
+
|
|
18
|
+
// notify-reaction's single-reactor push title template. `{name}` is
|
|
19
|
+
// substituted with the reactor's display name (or FALLBACK_ACTOR_NAME).
|
|
20
|
+
// Neutral default; an app with its own reaction semantics overrides this
|
|
21
|
+
// secret rather than the function code.
|
|
22
|
+
export const REACTION_PUSH_TEXT =
|
|
23
|
+
Deno.env.get("COMMUNITY_REACTION_PUSH_TEXT") ?? "{name} is thinking of you";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Shared moderation policy: OpenAI omni-moderation categories, score
|
|
2
|
+
// thresholds and the API call. Single source of truth for moderate-one,
|
|
3
|
+
// daily-moderation and update-profile.
|
|
4
|
+
|
|
5
|
+
// Categories that always hide a piece of content when OpenAI's own boolean
|
|
6
|
+
// flag is true, regardless of MODERATION_EXCLUDED_CATEGORIES below.
|
|
7
|
+
export const CATEGORIES_TO_HIDE = [
|
|
8
|
+
"hate",
|
|
9
|
+
"hate/threatening",
|
|
10
|
+
"harassment",
|
|
11
|
+
"harassment/threatening",
|
|
12
|
+
"sexual",
|
|
13
|
+
"sexual/minors",
|
|
14
|
+
"violence",
|
|
15
|
+
"violence/graphic",
|
|
16
|
+
"self-harm",
|
|
17
|
+
"self-harm/intent",
|
|
18
|
+
"self-harm/instructions",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Comma-separated category list (e.g. "sexual,sexual/minors") an app can set
|
|
22
|
+
// to skip the supplementary raw-score check below for those categories —
|
|
23
|
+
// e.g. a wellness app allowing legitimate intimacy discussion sets
|
|
24
|
+
// MODERATION_EXCLUDED_CATEGORIES=sexual. The boolean check above still
|
|
25
|
+
// applies to every category regardless: this only silences the extra
|
|
26
|
+
// under-scored-content catch for the excluded ones. Default empty (no
|
|
27
|
+
// exclusion) — a neutral SDK default.
|
|
28
|
+
const EXCLUDED_CATEGORIES = new Set(
|
|
29
|
+
(Deno.env.get("MODERATION_EXCLUDED_CATEGORIES") ?? "")
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((c) => c.trim())
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Harm categories also hidden on a raw SCORE >= threshold, not just OpenAI's
|
|
36
|
+
// conservative boolean, to catch under-scored insults/harassment (incl. other
|
|
37
|
+
// languages). Starts from CATEGORIES_TO_HIDE, minus MODERATION_EXCLUDED_CATEGORIES.
|
|
38
|
+
export const SCORE_HIDE = CATEGORIES_TO_HIDE.filter((c) => !EXCLUDED_CATEGORIES.has(c));
|
|
39
|
+
|
|
40
|
+
const parsedThreshold = Number(Deno.env.get("MODERATION_SCORE_THRESHOLD"));
|
|
41
|
+
// Number("") is 0, and Number.isFinite(0) is true — without the > 0 guard an
|
|
42
|
+
// unset/blank env var silently sets the threshold to 0, which hides every
|
|
43
|
+
// piece of content instead of falling back to the 0.5 default.
|
|
44
|
+
export const SCORE_THRESHOLD =
|
|
45
|
+
Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : 0.5;
|
|
46
|
+
|
|
47
|
+
export interface ModerationResult {
|
|
48
|
+
categories: Record<string, boolean>;
|
|
49
|
+
category_scores: Record<string, number>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function flaggedCategories(result: ModerationResult): string[] {
|
|
53
|
+
const cats = new Set<string>();
|
|
54
|
+
for (const [cat, v] of Object.entries(result.categories)) {
|
|
55
|
+
if (v && CATEGORIES_TO_HIDE.includes(cat)) cats.add(cat);
|
|
56
|
+
}
|
|
57
|
+
for (const [cat, score] of Object.entries(result.category_scores)) {
|
|
58
|
+
if (SCORE_HIDE.includes(cat) && score >= SCORE_THRESHOLD) cats.add(cat);
|
|
59
|
+
}
|
|
60
|
+
return [...cats];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Call once at module top-level in every function that moderates content
|
|
64
|
+
// (moderate-one, daily-moderation, update-profile) so a missing secret fails
|
|
65
|
+
// loudly at boot instead of degrading silently into "fail closed" forever
|
|
66
|
+
// (moderateInput would otherwise just log a 401 from OpenAI on every call).
|
|
67
|
+
export function assertModerationConfigured(): void {
|
|
68
|
+
if (!Deno.env.get("OPENAI_API_KEY")) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
"community-sdk: OPENAI_API_KEY is not set. Moderation cannot run — set it with " +
|
|
71
|
+
"`supabase secrets set OPENAI_API_KEY=sk-...`.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ImageInput = { type: "image_url"; image_url: { url: string } }[];
|
|
77
|
+
|
|
78
|
+
// POSTs to OpenAI moderation. `input` is a string for text or the image_url
|
|
79
|
+
// content-part array for an avatar. Returns null on a non-OK response so the
|
|
80
|
+
// caller can fail closed (nothing published / nothing written).
|
|
81
|
+
export async function moderateInput(
|
|
82
|
+
input: string | ImageInput,
|
|
83
|
+
): Promise<{ flagged: boolean; reason: string | null } | null> {
|
|
84
|
+
const res = await fetch("https://api.openai.com/v1/moderations", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${Deno.env.get("OPENAI_API_KEY")}`,
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({ model: "omni-moderation-latest", input }),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
// Logged for ops (e.g. image_parse_error on a corrupt upload).
|
|
94
|
+
console.error("moderation api error", res.status, (await res.text()).slice(0, 300));
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const { results } = await res.json();
|
|
98
|
+
const cats = flaggedCategories(results[0]);
|
|
99
|
+
return cats.length > 0
|
|
100
|
+
? { flagged: true, reason: cats.join(",") }
|
|
101
|
+
: { flagged: false, reason: null };
|
|
102
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared Expo push sender. Used by notify-comment, notify-like,
|
|
2
|
+
// notify-reaction and broadcast-post (push module).
|
|
3
|
+
|
|
4
|
+
export interface ExpoPushMessage {
|
|
5
|
+
to: string;
|
|
6
|
+
title: string;
|
|
7
|
+
body?: string;
|
|
8
|
+
data: Record<string, string>;
|
|
9
|
+
// badge: 1 is a "something new" dot, not a real unread count — the app
|
|
10
|
+
// clears it on every foreground.
|
|
11
|
+
badge?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Sends in chunks of 100 (Expo API limit). Best-effort: push failures must
|
|
15
|
+
// never fail the calling webhook. Projects with Enhanced Security for push
|
|
16
|
+
// must set the EXPO_ACCESS_TOKEN function secret.
|
|
17
|
+
export async function sendExpoPushBatch(messages: ExpoPushMessage[]): Promise<void> {
|
|
18
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
19
|
+
const accessToken = Deno.env.get("EXPO_ACCESS_TOKEN");
|
|
20
|
+
if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
|
|
21
|
+
for (let i = 0; i < messages.length; i += 100) {
|
|
22
|
+
const chunk = messages.slice(i, i + 100);
|
|
23
|
+
await fetch("https://exp.host/--/api/v2/push/send", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers,
|
|
26
|
+
body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
|
|
27
|
+
}).catch(() => {});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
|
|
32
|
+
await sendExpoPushBatch([message]);
|
|
33
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function postToSlack(payload: unknown): Promise<void> {
|
|
2
|
+
const url = Deno.env.get("SLACK_WEBHOOK_URL");
|
|
3
|
+
if (!url) {
|
|
4
|
+
console.log("community-sdk: SLACK_WEBHOOK_URL not set, skipping Slack notification");
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
await fetch(url, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "content-type": "application/json" },
|
|
10
|
+
body: JSON.stringify(payload),
|
|
11
|
+
});
|
|
12
|
+
}
|