redclove 0.0.0-stage → 0.1.1
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 +24 -2
- package/dist/add.d.ts +52 -0
- package/dist/add.js +160 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +125 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/project.d.ts +16 -0
- package/dist/project.js +40 -0
- package/dist/registry.d.ts +58 -0
- package/dist/registry.js +36 -0
- package/package.json +19 -4
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# redclove
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
npx redclove add cart-orders payments # several at once
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
| Command | What it does |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `redclove add <block...>` | Copies the block into `src/blocks/<block>/`, its migration into `supabase/migrations/`, and installs its packages |
|
|
13
|
+
| `redclove list` | Shows every block and whether it is ready |
|
|
14
|
+
| `redclove diff <block>` | Compares your copy with the latest version |
|
|
15
|
+
| `redclove init` | Writes `redclove.json` so you can change where files go |
|
|
16
|
+
|
|
17
|
+
Options: `--yes`, `--overwrite`, `--no-install`, `--cwd <dir>`, `--registry <url-or-folder>`.
|
|
18
|
+
|
|
19
|
+
Blocks that need another block (rbac needs auth, cart-orders needs catalog) add it first, and every package the blocks need is installed in one go. Files you have edited are never replaced without asking (or without `--overwrite`).
|
|
20
|
+
|
|
21
|
+
Every block comes with its tests. Run them with `npx vitest run src/blocks --maxWorkers=4`; each test file starts an in-process Postgres, so no database is needed (and capping workers keeps memory in check when you have many blocks).
|
|
22
|
+
|
|
23
|
+
Packages your project already lists are left at the version you chose.
|
|
24
|
+
|
|
25
|
+
Browse blocks at https://blocks.redclove.space.
|
package/dist/add.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
dependencies: string[];
|
|
25
|
+
devDependencies: string[];
|
|
26
|
+
/** Set when this block was added because another one requires it. */
|
|
27
|
+
requiredBy?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare class BlockNotFoundError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** The specs whose package isn't in the project's package.json yet, so a version the user already chose is never changed. */
|
|
32
|
+
export declare function missingPackages(cwd: string, specs: string[]): Promise<string[]>;
|
|
33
|
+
export declare function addBlock(slug: string, opts: AddOptions): Promise<AddResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Adds blocks and, first, any blocks they require that the project doesn't have yet.
|
|
36
|
+
* Results come back in the order the blocks were added.
|
|
37
|
+
*/
|
|
38
|
+
export declare function addBlocks(slugs: string[], opts: AddOptions & {
|
|
39
|
+
onStart?: (slug: string, requiredBy?: string) => void;
|
|
40
|
+
}): Promise<AddResult[]>;
|
|
41
|
+
export type DiffResult = {
|
|
42
|
+
slug: string;
|
|
43
|
+
installed: string | null;
|
|
44
|
+
latest: string;
|
|
45
|
+
changed: string[];
|
|
46
|
+
missing: string[];
|
|
47
|
+
};
|
|
48
|
+
/** Compares the block's files in the project with the latest version in the registry. */
|
|
49
|
+
export declare function diffBlock(slug: string, opts: {
|
|
50
|
+
cwd: string;
|
|
51
|
+
registry?: string;
|
|
52
|
+
}): Promise<DiffResult>;
|
package/dist/add.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
/** A migration timestamp later than any already in the folder, so blocks added together apply in the order they were added. */
|
|
10
|
+
async function nextStamp(dir) {
|
|
11
|
+
let n = Number(new Date().toISOString().replace(/\D/g, "").slice(0, 14));
|
|
12
|
+
if (existsSync(dir))
|
|
13
|
+
for (const f of await readdir(dir))
|
|
14
|
+
n = Math.max(n, Number(/^(\d{14})_/.exec(f)?.[1] ?? 0) + 1);
|
|
15
|
+
return String(n);
|
|
16
|
+
}
|
|
17
|
+
/** An existing migration this CLI wrote for the block, so re-running add updates it instead of adding another. */
|
|
18
|
+
async function findMigration(dir, slug) {
|
|
19
|
+
if (!existsSync(dir))
|
|
20
|
+
return null;
|
|
21
|
+
const match = (await readdir(dir)).find((f) => f.endsWith(`_redclove_${slug}.sql`));
|
|
22
|
+
return match ? join(dir, match) : null;
|
|
23
|
+
}
|
|
24
|
+
/** "vitest@^4.1.0" → "vitest", "@types/pg" → "@types/pg". */
|
|
25
|
+
const packageName = (spec) => spec.replace(/^(@?[^@]+)@.*$/, "$1");
|
|
26
|
+
/** The specs whose package isn't in the project's package.json yet, so a version the user already chose is never changed. */
|
|
27
|
+
export async function missingPackages(cwd, specs) {
|
|
28
|
+
let have = new Set();
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
31
|
+
have = new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// No package.json yet: install everything.
|
|
35
|
+
}
|
|
36
|
+
return [...new Set(specs)].filter((s) => !have.has(packageName(s))).sort();
|
|
37
|
+
}
|
|
38
|
+
/** Installs packages the project doesn't have yet with its package manager. Returns false if a command failed. */
|
|
39
|
+
async function installPackages(cwd, allDeps, allDevDeps, opts) {
|
|
40
|
+
const log = opts.log ?? (() => { });
|
|
41
|
+
const pm = detectPackageManager(cwd);
|
|
42
|
+
const deps = await missingPackages(cwd, allDeps);
|
|
43
|
+
const devDeps = await missingPackages(cwd, allDevDeps);
|
|
44
|
+
let ok = true;
|
|
45
|
+
for (const [list, dev] of [[deps, false], [devDeps, true]]) {
|
|
46
|
+
if (!list.length)
|
|
47
|
+
continue;
|
|
48
|
+
const [cmd, ...args] = addCommand(pm, [...list], dev);
|
|
49
|
+
log(`$ ${cmd} ${args.join(" ")}`);
|
|
50
|
+
const run = spawnSync(cmd, args, { cwd, stdio: opts.installOutput ?? "inherit", maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32" });
|
|
51
|
+
if (run.status !== 0)
|
|
52
|
+
ok = false;
|
|
53
|
+
}
|
|
54
|
+
return ok;
|
|
55
|
+
}
|
|
56
|
+
export async function addBlock(slug, opts) {
|
|
57
|
+
const { cwd } = opts;
|
|
58
|
+
const onConflict = opts.onConflict ?? "skip";
|
|
59
|
+
const registry = registryUrl(opts.registry);
|
|
60
|
+
const block = await fetchBlock(slug, registry);
|
|
61
|
+
if (!block)
|
|
62
|
+
throw new BlockNotFoundError(`No block named "${slug}" is ready yet. Run "redclove list" to see what is.`);
|
|
63
|
+
const config = (await readConfig(cwd)) ?? defaultConfig(cwd);
|
|
64
|
+
const result = {
|
|
65
|
+
slug, version: block.version, written: [], unchanged: [], skipped: [], installed: [], installFailed: false,
|
|
66
|
+
env: block.env, usage: block.usage, dependencies: block.dependencies, devDependencies: block.devDependencies,
|
|
67
|
+
};
|
|
68
|
+
const targets = block.files.map((f) => ({ path: join(cwd, config.blocksDir, slug, f.path), content: f.content }));
|
|
69
|
+
if (block.migration) {
|
|
70
|
+
const dir = join(cwd, config.migrationsDir);
|
|
71
|
+
targets.push({ path: (await findMigration(dir, slug)) ?? join(dir, `${await nextStamp(dir)}_redclove_${slug}.sql`), content: block.migration.content });
|
|
72
|
+
}
|
|
73
|
+
for (const t of targets) {
|
|
74
|
+
const rel = relative(cwd, t.path);
|
|
75
|
+
if (!rel || rel.startsWith(".."))
|
|
76
|
+
throw new Error(`Refusing to write outside the project: ${t.path}`);
|
|
77
|
+
if (existsSync(t.path)) {
|
|
78
|
+
if ((await readFile(t.path, "utf8")) === t.content) {
|
|
79
|
+
result.unchanged.push(rel);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const replace = onConflict === "overwrite" || (typeof onConflict === "function" && (await onConflict(rel)));
|
|
83
|
+
if (!replace) {
|
|
84
|
+
result.skipped.push(rel);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await mkdir(dirname(t.path), { recursive: true });
|
|
89
|
+
await writeFile(t.path, t.content);
|
|
90
|
+
result.written.push(rel);
|
|
91
|
+
}
|
|
92
|
+
if (opts.install !== false) {
|
|
93
|
+
const ok = await installPackages(cwd, block.dependencies, block.devDependencies, opts);
|
|
94
|
+
if (ok)
|
|
95
|
+
result.installed.push(...block.dependencies, ...block.devDependencies);
|
|
96
|
+
else
|
|
97
|
+
result.installFailed = true;
|
|
98
|
+
}
|
|
99
|
+
config.blocks[slug] = block.version;
|
|
100
|
+
await writeConfig(cwd, config);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Adds blocks and, first, any blocks they require that the project doesn't have yet.
|
|
105
|
+
* Results come back in the order the blocks were added.
|
|
106
|
+
*/
|
|
107
|
+
export async function addBlocks(slugs, opts) {
|
|
108
|
+
const registry = registryUrl(opts.registry);
|
|
109
|
+
const installed = ((await readConfig(opts.cwd)) ?? defaultConfig(opts.cwd)).blocks;
|
|
110
|
+
const order = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const visit = async (slug, requiredBy) => {
|
|
113
|
+
if (seen.has(slug))
|
|
114
|
+
return;
|
|
115
|
+
seen.add(slug);
|
|
116
|
+
const block = await fetchBlock(slug, registry);
|
|
117
|
+
if (!block)
|
|
118
|
+
throw new BlockNotFoundError(`No block named "${slug}" is ready yet${requiredBy ? ` (${requiredBy} needs it)` : ""}. Run "redclove list" to see what is.`);
|
|
119
|
+
for (const r of block.requires ?? [])
|
|
120
|
+
if (!installed[r] && !slugs.includes(r))
|
|
121
|
+
await visit(r, slug);
|
|
122
|
+
order.push({ slug, requiredBy });
|
|
123
|
+
};
|
|
124
|
+
for (const slug of slugs)
|
|
125
|
+
await visit(slug);
|
|
126
|
+
// Copy every block first, then install all their packages in one go.
|
|
127
|
+
const results = [];
|
|
128
|
+
for (const { slug, requiredBy } of order) {
|
|
129
|
+
opts.onStart?.(slug, requiredBy);
|
|
130
|
+
results.push({ ...(await addBlock(slug, { ...opts, install: false })), requiredBy });
|
|
131
|
+
}
|
|
132
|
+
if (opts.install !== false) {
|
|
133
|
+
const ok = await installPackages(opts.cwd, results.flatMap((r) => r.dependencies), results.flatMap((r) => r.devDependencies), opts);
|
|
134
|
+
for (const r of results) {
|
|
135
|
+
if (ok)
|
|
136
|
+
r.installed = [...r.dependencies, ...r.devDependencies];
|
|
137
|
+
else
|
|
138
|
+
r.installFailed = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return results;
|
|
142
|
+
}
|
|
143
|
+
/** Compares the block's files in the project with the latest version in the registry. */
|
|
144
|
+
export async function diffBlock(slug, opts) {
|
|
145
|
+
const block = await fetchBlock(slug, registryUrl(opts.registry));
|
|
146
|
+
if (!block)
|
|
147
|
+
throw new BlockNotFoundError(`No block named "${slug}".`);
|
|
148
|
+
const config = (await readConfig(opts.cwd)) ?? defaultConfig(opts.cwd);
|
|
149
|
+
const changed = [];
|
|
150
|
+
const missing = [];
|
|
151
|
+
for (const f of block.files) {
|
|
152
|
+
const path = join(opts.cwd, config.blocksDir, slug, f.path);
|
|
153
|
+
const rel = relative(opts.cwd, path);
|
|
154
|
+
if (!existsSync(path))
|
|
155
|
+
missing.push(rel);
|
|
156
|
+
else if ((await readFile(path, "utf8")) !== f.content)
|
|
157
|
+
changed.push(rel);
|
|
158
|
+
}
|
|
159
|
+
return { slug, installed: config.blocks[slug] ?? null, latest: block.version, changed, missing };
|
|
160
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { addBlocks, 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
|
+
const results = await addBlocks(args, {
|
|
55
|
+
cwd, registry, onConflict, install: !values["no-install"], log: (l) => console.log(dim(l)),
|
|
56
|
+
onStart: (slug, requiredBy) => console.log(dim(`Adding ${slug}${requiredBy ? ` (${requiredBy} needs it)` : ""}…`)),
|
|
57
|
+
});
|
|
58
|
+
for (const r of results)
|
|
59
|
+
report(r);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
rl?.close();
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
case "list": {
|
|
67
|
+
const blocks = await fetchIndex(registry);
|
|
68
|
+
const width = Math.max(...blocks.map((b) => b.slug.length));
|
|
69
|
+
for (const b of blocks)
|
|
70
|
+
console.log(`${b.available ? "●" : "○"} ${b.slug.padEnd(width)} ${dim(b.available ? b.summary : "coming soon")}`);
|
|
71
|
+
console.log(dim(`\n● ready to add ○ in the works`));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
case "diff": {
|
|
75
|
+
if (args.length !== 1)
|
|
76
|
+
throw new UsageError("Usage: npx redclove diff <block>");
|
|
77
|
+
const d = await diffBlock(args[0], { cwd, registry });
|
|
78
|
+
console.log(`${d.slug}: installed ${d.installed ?? "unknown"}, latest ${d.latest}`);
|
|
79
|
+
for (const f of d.changed)
|
|
80
|
+
console.log(` changed ${f}`);
|
|
81
|
+
for (const f of d.missing)
|
|
82
|
+
console.log(` missing ${f}`);
|
|
83
|
+
if (!d.changed.length && !d.missing.length)
|
|
84
|
+
console.log(" up to date");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case "init": {
|
|
88
|
+
if (await readConfig(cwd))
|
|
89
|
+
return void console.log(`${CONFIG_FILE} already exists.`);
|
|
90
|
+
const config = defaultConfig(cwd);
|
|
91
|
+
await writeConfig(cwd, config);
|
|
92
|
+
console.log(`Wrote ${CONFIG_FILE}: blocks go in ${config.blocksDir}/, migrations in ${config.migrationsDir}/`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
throw new UsageError(`Unknown command "${command}".\n\n${HELP}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function report(r) {
|
|
100
|
+
console.log(bold(`\n${r.slug}`) + (r.requiredBy ? dim(` added because ${r.requiredBy} needs it`) : ""));
|
|
101
|
+
for (const f of r.written)
|
|
102
|
+
console.log(` + ${f}`);
|
|
103
|
+
for (const f of r.unchanged)
|
|
104
|
+
console.log(dim(` = ${f}`));
|
|
105
|
+
for (const f of r.skipped)
|
|
106
|
+
console.log(` ! kept your ${f}`);
|
|
107
|
+
if (r.installFailed)
|
|
108
|
+
console.log(` ! installing packages failed; install them yourself and run add again`);
|
|
109
|
+
const needed = r.env.filter((e) => !process.env[e.name]);
|
|
110
|
+
if (needed.length) {
|
|
111
|
+
console.log(bold("\n Set these in your .env:"));
|
|
112
|
+
for (const e of needed)
|
|
113
|
+
console.log(` ${e.name}${e.optional ? " (optional)" : ""} ${dim(e.description)}`);
|
|
114
|
+
}
|
|
115
|
+
if (r.written.some((f) => f.endsWith(".sql")))
|
|
116
|
+
console.log(bold("\n Apply the migration:") + " supabase db push");
|
|
117
|
+
console.log(bold("\n Use it:"));
|
|
118
|
+
console.log(r.usage.replace(/^/gm, " "));
|
|
119
|
+
}
|
|
120
|
+
class UsageError extends Error {
|
|
121
|
+
}
|
|
122
|
+
main().catch((e) => {
|
|
123
|
+
console.error(e instanceof UsageError ? e.message : `redclove: ${e.message}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { addBlock, addBlocks, diffBlock, missingPackages, 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, addCommand, CONFIG_FILE, type Config } from "./project.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { addBlock, addBlocks, diffBlock, missingPackages, BlockNotFoundError } from "./add.js";
|
|
2
|
+
export { fetchBlock, fetchIndex, registryUrl, DEFAULT_REGISTRY } from "./registry.js";
|
|
3
|
+
export { readConfig, writeConfig, defaultConfig, detectPackageManager, addCommand, 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[];
|
package/dist/project.js
ADDED
|
@@ -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,58 @@
|
|
|
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
|
+
/** Present in registries built after 0.1.0. */
|
|
9
|
+
features?: string[];
|
|
10
|
+
tests?: string[];
|
|
11
|
+
files?: string[];
|
|
12
|
+
migration?: string | null;
|
|
13
|
+
usage?: string;
|
|
14
|
+
requires?: string[];
|
|
15
|
+
/** A normal request and a hostile one, with the block's answers. */
|
|
16
|
+
exchange?: {
|
|
17
|
+
honest: Exchange;
|
|
18
|
+
hostile: Exchange;
|
|
19
|
+
labels?: [string, string];
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export type Exchange = {
|
|
23
|
+
who?: string;
|
|
24
|
+
req: string;
|
|
25
|
+
headers?: string[];
|
|
26
|
+
body?: unknown;
|
|
27
|
+
status: string;
|
|
28
|
+
res?: unknown;
|
|
29
|
+
};
|
|
30
|
+
export type EnvVar = {
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
optional?: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type RegistryBlock = {
|
|
36
|
+
slug: string;
|
|
37
|
+
name: string;
|
|
38
|
+
summary: string;
|
|
39
|
+
version: string;
|
|
40
|
+
files: {
|
|
41
|
+
path: string;
|
|
42
|
+
content: string;
|
|
43
|
+
}[];
|
|
44
|
+
migration: {
|
|
45
|
+
name: string;
|
|
46
|
+
content: string;
|
|
47
|
+
} | null;
|
|
48
|
+
dependencies: string[];
|
|
49
|
+
devDependencies: string[];
|
|
50
|
+
env: EnvVar[];
|
|
51
|
+
usage: string;
|
|
52
|
+
/** Other blocks this one imports; they are added first. Absent in registries built before 0.1.1. */
|
|
53
|
+
requires?: string[];
|
|
54
|
+
};
|
|
55
|
+
/** The registry to read from: REDCLOVE_REGISTRY if set, otherwise the public one. It may be a URL or a local folder. */
|
|
56
|
+
export declare const registryUrl: (override?: string) => string;
|
|
57
|
+
export declare function fetchIndex(registry?: string): Promise<IndexEntry[]>;
|
|
58
|
+
export declare function fetchBlock(slug: string, registry?: string): Promise<RegistryBlock | null>;
|
package/dist/registry.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|