redclove 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # redclove
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Add tested backend blocks to your project. Each block is plain source code copied into your repo, with its tests and its SQL migration.
4
+
5
+ ```bash
6
+ npx redclove add auth
7
+ ```
8
+
9
+ | Command | What it does |
10
+ | --- | --- |
11
+ | `redclove add <block...>` | Copies the block into `src/blocks/<block>/`, its migration into `supabase/migrations/`, and installs its packages |
12
+ | `redclove list` | Shows every block and whether it is ready |
13
+ | `redclove diff <block>` | Compares your copy with the latest version |
14
+ | `redclove init` | Writes `redclove.json` so you can change where files go |
15
+
16
+ Options: `--yes`, `--overwrite`, `--no-install`, `--cwd <dir>`, `--registry <url-or-folder>`.
17
+
18
+ Files you have edited are never replaced without asking (or without `--overwrite`).
19
+
20
+ Browse blocks at https://blocks.redclove.space.
package/dist/add.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { type EnvVar } from "./registry.js";
2
+ /** What to do when a file the block writes is already there and differs. */
3
+ export type ConflictPolicy = "skip" | "overwrite" | ((path: string) => Promise<boolean>);
4
+ export type AddOptions = {
5
+ cwd: string;
6
+ registry?: string;
7
+ onConflict?: ConflictPolicy;
8
+ /** Install the block's npm dependencies. Defaults to true. */
9
+ install?: boolean;
10
+ /** "pipe" hides the package manager's output, for callers that own stdout (like an MCP server). */
11
+ installOutput?: "inherit" | "pipe";
12
+ log?: (line: string) => void;
13
+ };
14
+ export type AddResult = {
15
+ slug: string;
16
+ version: string;
17
+ written: string[];
18
+ unchanged: string[];
19
+ skipped: string[];
20
+ installed: string[];
21
+ installFailed: boolean;
22
+ env: EnvVar[];
23
+ usage: string;
24
+ };
25
+ export declare class BlockNotFoundError extends Error {
26
+ }
27
+ export declare function addBlock(slug: string, opts: AddOptions): Promise<AddResult>;
28
+ export type DiffResult = {
29
+ slug: string;
30
+ installed: string | null;
31
+ latest: string;
32
+ changed: string[];
33
+ missing: string[];
34
+ };
35
+ /** Compares the block's files in the project with the latest version in the registry. */
36
+ export declare function diffBlock(slug: string, opts: {
37
+ cwd: string;
38
+ registry?: string;
39
+ }): Promise<DiffResult>;
package/dist/add.js ADDED
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { addCommand, defaultConfig, detectPackageManager, readConfig, writeConfig } from "./project.js";
6
+ import { fetchBlock, registryUrl } from "./registry.js";
7
+ export class BlockNotFoundError extends Error {
8
+ }
9
+ const stamp = () => new Date().toISOString().replace(/\D/g, "").slice(0, 14);
10
+ /** An existing migration this CLI wrote for the block, so re-running add updates it instead of adding another. */
11
+ async function findMigration(dir, slug) {
12
+ if (!existsSync(dir))
13
+ return null;
14
+ const match = (await readdir(dir)).find((f) => f.endsWith(`_redclove_${slug}.sql`));
15
+ return match ? join(dir, match) : null;
16
+ }
17
+ export async function addBlock(slug, opts) {
18
+ const { cwd } = opts;
19
+ const log = opts.log ?? (() => { });
20
+ const onConflict = opts.onConflict ?? "skip";
21
+ const registry = registryUrl(opts.registry);
22
+ const block = await fetchBlock(slug, registry);
23
+ if (!block)
24
+ throw new BlockNotFoundError(`No block named "${slug}" is ready yet. Run "redclove list" to see what is.`);
25
+ const config = (await readConfig(cwd)) ?? defaultConfig(cwd);
26
+ const result = {
27
+ slug, version: block.version, written: [], unchanged: [], skipped: [], installed: [], installFailed: false,
28
+ env: block.env, usage: block.usage,
29
+ };
30
+ const targets = block.files.map((f) => ({ path: join(cwd, config.blocksDir, slug, f.path), content: f.content }));
31
+ if (block.migration) {
32
+ const dir = join(cwd, config.migrationsDir);
33
+ targets.push({ path: (await findMigration(dir, slug)) ?? join(dir, `${stamp()}_redclove_${slug}.sql`), content: block.migration.content });
34
+ }
35
+ for (const t of targets) {
36
+ const rel = relative(cwd, t.path);
37
+ if (!rel || rel.startsWith(".."))
38
+ throw new Error(`Refusing to write outside the project: ${t.path}`);
39
+ if (existsSync(t.path)) {
40
+ if ((await readFile(t.path, "utf8")) === t.content) {
41
+ result.unchanged.push(rel);
42
+ continue;
43
+ }
44
+ const replace = onConflict === "overwrite" || (typeof onConflict === "function" && (await onConflict(rel)));
45
+ if (!replace) {
46
+ result.skipped.push(rel);
47
+ continue;
48
+ }
49
+ }
50
+ await mkdir(dirname(t.path), { recursive: true });
51
+ await writeFile(t.path, t.content);
52
+ result.written.push(rel);
53
+ }
54
+ if (opts.install !== false) {
55
+ const pm = detectPackageManager(cwd);
56
+ for (const [deps, dev] of [[block.dependencies, false], [block.devDependencies, true]]) {
57
+ if (!deps.length)
58
+ continue;
59
+ const [cmd, ...args] = addCommand(pm, [...deps], dev);
60
+ log(`$ ${cmd} ${args.join(" ")}`);
61
+ const run = spawnSync(cmd, args, { cwd, stdio: opts.installOutput ?? "inherit", maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32" });
62
+ if (run.status === 0)
63
+ result.installed.push(...deps);
64
+ else
65
+ result.installFailed = true;
66
+ }
67
+ }
68
+ config.blocks[slug] = block.version;
69
+ await writeConfig(cwd, config);
70
+ return result;
71
+ }
72
+ /** Compares the block's files in the project with the latest version in the registry. */
73
+ export async function diffBlock(slug, opts) {
74
+ const block = await fetchBlock(slug, registryUrl(opts.registry));
75
+ if (!block)
76
+ throw new BlockNotFoundError(`No block named "${slug}".`);
77
+ const config = (await readConfig(opts.cwd)) ?? defaultConfig(opts.cwd);
78
+ const changed = [];
79
+ const missing = [];
80
+ for (const f of block.files) {
81
+ const path = join(opts.cwd, config.blocksDir, slug, f.path);
82
+ const rel = relative(opts.cwd, path);
83
+ if (!existsSync(path))
84
+ missing.push(rel);
85
+ else if ((await readFile(path, "utf8")) !== f.content)
86
+ changed.push(rel);
87
+ }
88
+ return { slug, installed: config.blocks[slug] ?? null, latest: block.version, changed, missing };
89
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline/promises";
3
+ import { parseArgs } from "node:util";
4
+ import { addBlock, diffBlock } from "./add.js";
5
+ import { CONFIG_FILE, defaultConfig, readConfig, writeConfig } from "./project.js";
6
+ import { fetchIndex } from "./registry.js";
7
+ const HELP = `redclove: add tested backend blocks to your project
8
+
9
+ Usage
10
+ npx redclove add <block...> copy blocks into your project and install their packages
11
+ npx redclove list show every block and whether it is ready
12
+ npx redclove diff <block> compare your copy of a block with the latest one
13
+ npx redclove init write ${CONFIG_FILE} with where blocks and migrations go
14
+
15
+ Options
16
+ -y, --yes don't ask; keep files you have changed (with --overwrite, replace them)
17
+ --overwrite replace files that differ from the block
18
+ --no-install copy files but don't install packages
19
+ --cwd <dir> project folder (default: current folder)
20
+ --registry <u> registry URL or folder (default: https://blocks.redclove.space/r)
21
+ -h, --help show this help
22
+ `;
23
+ const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
24
+ const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
25
+ async function main() {
26
+ const { values, positionals } = parseArgs({
27
+ allowPositionals: true,
28
+ options: {
29
+ yes: { type: "boolean", short: "y" },
30
+ overwrite: { type: "boolean" },
31
+ "no-install": { type: "boolean" },
32
+ cwd: { type: "string" },
33
+ registry: { type: "string" },
34
+ help: { type: "boolean", short: "h" },
35
+ },
36
+ });
37
+ const [command, ...args] = positionals;
38
+ const cwd = values.cwd ?? process.cwd();
39
+ const registry = values.registry;
40
+ if (values.help || !command)
41
+ return void process.stdout.write(HELP);
42
+ switch (command) {
43
+ case "add": {
44
+ if (!args.length)
45
+ throw new UsageError("Name at least one block, for example: npx redclove add auth");
46
+ const interactive = process.stdin.isTTY && !values.yes && !values.overwrite;
47
+ const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
48
+ const onConflict = values.overwrite
49
+ ? "overwrite"
50
+ : rl
51
+ ? async (path) => /^y/i.test(await rl.question(`${path} has changes of yours. Replace it? (y/N) `))
52
+ : "skip";
53
+ try {
54
+ for (const slug of args) {
55
+ console.log(bold(`\nAdding ${slug}`));
56
+ report(await addBlock(slug, { cwd, registry, onConflict, install: !values["no-install"], log: (l) => console.log(dim(l)) }));
57
+ }
58
+ }
59
+ finally {
60
+ rl?.close();
61
+ }
62
+ return;
63
+ }
64
+ case "list": {
65
+ const blocks = await fetchIndex(registry);
66
+ const width = Math.max(...blocks.map((b) => b.slug.length));
67
+ for (const b of blocks)
68
+ console.log(`${b.available ? "●" : "○"} ${b.slug.padEnd(width)} ${dim(b.available ? b.summary : "coming soon")}`);
69
+ console.log(dim(`\n● ready to add ○ in the works`));
70
+ return;
71
+ }
72
+ case "diff": {
73
+ if (args.length !== 1)
74
+ throw new UsageError("Usage: npx redclove diff <block>");
75
+ const d = await diffBlock(args[0], { cwd, registry });
76
+ console.log(`${d.slug}: installed ${d.installed ?? "unknown"}, latest ${d.latest}`);
77
+ for (const f of d.changed)
78
+ console.log(` changed ${f}`);
79
+ for (const f of d.missing)
80
+ console.log(` missing ${f}`);
81
+ if (!d.changed.length && !d.missing.length)
82
+ console.log(" up to date");
83
+ return;
84
+ }
85
+ case "init": {
86
+ if (await readConfig(cwd))
87
+ return void console.log(`${CONFIG_FILE} already exists.`);
88
+ const config = defaultConfig(cwd);
89
+ await writeConfig(cwd, config);
90
+ console.log(`Wrote ${CONFIG_FILE}: blocks go in ${config.blocksDir}/, migrations in ${config.migrationsDir}/`);
91
+ return;
92
+ }
93
+ default:
94
+ throw new UsageError(`Unknown command "${command}".\n\n${HELP}`);
95
+ }
96
+ }
97
+ function report(r) {
98
+ for (const f of r.written)
99
+ console.log(` + ${f}`);
100
+ for (const f of r.unchanged)
101
+ console.log(dim(` = ${f}`));
102
+ for (const f of r.skipped)
103
+ console.log(` ! kept your ${f}`);
104
+ if (r.installFailed)
105
+ console.log(` ! installing packages failed; install them yourself and run add again`);
106
+ const needed = r.env.filter((e) => !process.env[e.name]);
107
+ if (needed.length) {
108
+ console.log(bold("\n Set these in your .env:"));
109
+ for (const e of needed)
110
+ console.log(` ${e.name}${e.optional ? " (optional)" : ""} ${dim(e.description)}`);
111
+ }
112
+ if (r.written.some((f) => f.endsWith(".sql")))
113
+ console.log(bold("\n Apply the migration:") + " supabase db push");
114
+ console.log(bold("\n Use it:"));
115
+ console.log(r.usage.replace(/^/gm, " "));
116
+ }
117
+ class UsageError extends Error {
118
+ }
119
+ main().catch((e) => {
120
+ console.error(e instanceof UsageError ? e.message : `redclove: ${e.message}`);
121
+ process.exit(1);
122
+ });
@@ -0,0 +1,3 @@
1
+ export { addBlock, diffBlock, BlockNotFoundError, type AddOptions, type AddResult, type ConflictPolicy, type DiffResult } from "./add.js";
2
+ export { fetchBlock, fetchIndex, registryUrl, DEFAULT_REGISTRY, type IndexEntry, type RegistryBlock, type EnvVar } from "./registry.js";
3
+ export { readConfig, writeConfig, defaultConfig, detectPackageManager, CONFIG_FILE, type Config } from "./project.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { addBlock, diffBlock, BlockNotFoundError } from "./add.js";
2
+ export { fetchBlock, fetchIndex, registryUrl, DEFAULT_REGISTRY } from "./registry.js";
3
+ export { readConfig, writeConfig, defaultConfig, detectPackageManager, CONFIG_FILE } from "./project.js";
@@ -0,0 +1,16 @@
1
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
2
+ export type Config = {
3
+ /** Where block folders go, relative to the project root. */
4
+ blocksDir: string;
5
+ /** Where SQL migrations go. */
6
+ migrationsDir: string;
7
+ /** Installed blocks and the registry version each was installed at. */
8
+ blocks: Record<string, string>;
9
+ };
10
+ export declare const CONFIG_FILE = "redclove.json";
11
+ export declare function detectPackageManager(cwd: string): PackageManager;
12
+ export declare function defaultConfig(cwd: string): Config;
13
+ export declare function readConfig(cwd: string): Promise<Config | null>;
14
+ export declare function writeConfig(cwd: string, config: Config): Promise<void>;
15
+ /** The command that adds packages with this package manager. */
16
+ export declare function addCommand(pm: PackageManager, packages: string[], dev: boolean): string[];
@@ -0,0 +1,40 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ export const CONFIG_FILE = "redclove.json";
5
+ export function detectPackageManager(cwd) {
6
+ if (existsSync(join(cwd, "pnpm-lock.yaml")))
7
+ return "pnpm";
8
+ if (existsSync(join(cwd, "yarn.lock")))
9
+ return "yarn";
10
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")))
11
+ return "bun";
12
+ return "npm";
13
+ }
14
+ export function defaultConfig(cwd) {
15
+ return {
16
+ blocksDir: existsSync(join(cwd, "src")) ? "src/blocks" : "blocks",
17
+ migrationsDir: "supabase/migrations",
18
+ blocks: {},
19
+ };
20
+ }
21
+ export async function readConfig(cwd) {
22
+ try {
23
+ const raw = JSON.parse(await readFile(join(cwd, CONFIG_FILE), "utf8"));
24
+ return { ...defaultConfig(cwd), ...raw, blocks: raw.blocks ?? {} };
25
+ }
26
+ catch (e) {
27
+ if (e.code === "ENOENT")
28
+ return null;
29
+ throw new Error(`Could not read ${CONFIG_FILE}: ${e.message}`);
30
+ }
31
+ }
32
+ export async function writeConfig(cwd, config) {
33
+ await writeFile(join(cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n");
34
+ }
35
+ /** The command that adds packages with this package manager. */
36
+ export function addCommand(pm, packages, dev) {
37
+ const verb = pm === "npm" ? "install" : "add";
38
+ const devFlag = dev ? [pm === "npm" ? "--save-dev" : "-D"] : [];
39
+ return [pm, verb, ...devFlag, ...packages];
40
+ }
@@ -0,0 +1,35 @@
1
+ export declare const DEFAULT_REGISTRY = "https://blocks.redclove.space/r";
2
+ export type IndexEntry = {
3
+ slug: string;
4
+ name: string;
5
+ category: string;
6
+ summary: string;
7
+ available: boolean;
8
+ };
9
+ export type EnvVar = {
10
+ name: string;
11
+ description: string;
12
+ optional?: boolean;
13
+ };
14
+ export type RegistryBlock = {
15
+ slug: string;
16
+ name: string;
17
+ summary: string;
18
+ version: string;
19
+ files: {
20
+ path: string;
21
+ content: string;
22
+ }[];
23
+ migration: {
24
+ name: string;
25
+ content: string;
26
+ } | null;
27
+ dependencies: string[];
28
+ devDependencies: string[];
29
+ env: EnvVar[];
30
+ usage: string;
31
+ };
32
+ /** The registry to read from: REDCLOVE_REGISTRY if set, otherwise the public one. It may be a URL or a local folder. */
33
+ export declare const registryUrl: (override?: string) => string;
34
+ export declare function fetchIndex(registry?: string): Promise<IndexEntry[]>;
35
+ export declare function fetchBlock(slug: string, registry?: string): Promise<RegistryBlock | null>;
@@ -0,0 +1,36 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export const DEFAULT_REGISTRY = "https://blocks.redclove.space/r";
5
+ /** The registry to read from: REDCLOVE_REGISTRY if set, otherwise the public one. It may be a URL or a local folder. */
6
+ export const registryUrl = (override) => (override ?? process.env.REDCLOVE_REGISTRY ?? DEFAULT_REGISTRY).replace(/\/$/, "");
7
+ async function load(registry, name) {
8
+ if (!/^https?:\/\//.test(registry)) {
9
+ const dir = registry.startsWith("file:") ? fileURLToPath(registry) : registry;
10
+ try {
11
+ return JSON.parse(await readFile(join(dir, name), "utf8"));
12
+ }
13
+ catch (e) {
14
+ if (e.code === "ENOENT")
15
+ return null;
16
+ throw e;
17
+ }
18
+ }
19
+ const res = await fetch(`${registry}/${name}`);
20
+ if (res.status === 404)
21
+ return null;
22
+ if (!res.ok)
23
+ throw new Error(`Registry answered ${res.status} for ${name}`);
24
+ return (await res.json());
25
+ }
26
+ export async function fetchIndex(registry = registryUrl()) {
27
+ const index = await load(registry, "index.json");
28
+ if (!index)
29
+ throw new Error(`No index.json at ${registry}`);
30
+ return index.blocks;
31
+ }
32
+ export async function fetchBlock(slug, registry = registryUrl()) {
33
+ if (!/^[a-z0-9-]+$/.test(slug))
34
+ return null;
35
+ return load(registry, `${slug}.json`);
36
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "redclove",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Add tested backend blocks (auth, payments, uploads…) to your project: npx redclove add auth",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": { "redclove": "dist/cli.js" },
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
11
+ "files": ["dist"],
12
+ "engines": { "node": ">=20" },
13
+ "homepage": "https://blocks.redclove.space",
14
+ "keywords": ["backend", "auth", "blocks", "cli", "supabase", "express"],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "node --test test/*.test.mjs",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "devDependencies": { "@types/node": "^20", "typescript": "^5" }
21
+ }