redclove 0.1.0 → 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 CHANGED
@@ -4,6 +4,7 @@ Add tested backend blocks to your project. Each block is plain source code copie
4
4
 
5
5
  ```bash
6
6
  npx redclove add auth
7
+ npx redclove add cart-orders payments # several at once
7
8
  ```
8
9
 
9
10
  | Command | What it does |
@@ -15,6 +16,10 @@ npx redclove add auth
15
16
 
16
17
  Options: `--yes`, `--overwrite`, `--no-install`, `--cwd <dir>`, `--registry <url-or-folder>`.
17
18
 
18
- Files you have edited are never replaced without asking (or without `--overwrite`).
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.
19
24
 
20
25
  Browse blocks at https://blocks.redclove.space.
package/dist/add.d.ts CHANGED
@@ -21,10 +21,23 @@ export type AddResult = {
21
21
  installFailed: boolean;
22
22
  env: EnvVar[];
23
23
  usage: string;
24
+ dependencies: string[];
25
+ devDependencies: string[];
26
+ /** Set when this block was added because another one requires it. */
27
+ requiredBy?: string;
24
28
  };
25
29
  export declare class BlockNotFoundError extends Error {
26
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[]>;
27
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[]>;
28
41
  export type DiffResult = {
29
42
  slug: string;
30
43
  installed: string | null;
package/dist/add.js CHANGED
@@ -6,7 +6,14 @@ import { addCommand, defaultConfig, detectPackageManager, readConfig, writeConfi
6
6
  import { fetchBlock, registryUrl } from "./registry.js";
7
7
  export class BlockNotFoundError extends Error {
8
8
  }
9
- const stamp = () => new Date().toISOString().replace(/\D/g, "").slice(0, 14);
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
+ }
10
17
  /** An existing migration this CLI wrote for the block, so re-running add updates it instead of adding another. */
11
18
  async function findMigration(dir, slug) {
12
19
  if (!existsSync(dir))
@@ -14,9 +21,40 @@ async function findMigration(dir, slug) {
14
21
  const match = (await readdir(dir)).find((f) => f.endsWith(`_redclove_${slug}.sql`));
15
22
  return match ? join(dir, match) : null;
16
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
+ }
17
56
  export async function addBlock(slug, opts) {
18
57
  const { cwd } = opts;
19
- const log = opts.log ?? (() => { });
20
58
  const onConflict = opts.onConflict ?? "skip";
21
59
  const registry = registryUrl(opts.registry);
22
60
  const block = await fetchBlock(slug, registry);
@@ -25,12 +63,12 @@ export async function addBlock(slug, opts) {
25
63
  const config = (await readConfig(cwd)) ?? defaultConfig(cwd);
26
64
  const result = {
27
65
  slug, version: block.version, written: [], unchanged: [], skipped: [], installed: [], installFailed: false,
28
- env: block.env, usage: block.usage,
66
+ env: block.env, usage: block.usage, dependencies: block.dependencies, devDependencies: block.devDependencies,
29
67
  };
30
68
  const targets = block.files.map((f) => ({ path: join(cwd, config.blocksDir, slug, f.path), content: f.content }));
31
69
  if (block.migration) {
32
70
  const dir = join(cwd, config.migrationsDir);
33
- targets.push({ path: (await findMigration(dir, slug)) ?? join(dir, `${stamp()}_redclove_${slug}.sql`), content: block.migration.content });
71
+ targets.push({ path: (await findMigration(dir, slug)) ?? join(dir, `${await nextStamp(dir)}_redclove_${slug}.sql`), content: block.migration.content });
34
72
  }
35
73
  for (const t of targets) {
36
74
  const rel = relative(cwd, t.path);
@@ -52,23 +90,56 @@ export async function addBlock(slug, opts) {
52
90
  result.written.push(rel);
53
91
  }
54
92
  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
- }
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;
67
98
  }
68
99
  config.blocks[slug] = block.version;
69
100
  await writeConfig(cwd, config);
70
101
  return result;
71
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
+ }
72
143
  /** Compares the block's files in the project with the latest version in the registry. */
73
144
  export async function diffBlock(slug, opts) {
74
145
  const block = await fetchBlock(slug, registryUrl(opts.registry));
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { parseArgs } from "node:util";
4
- import { addBlock, diffBlock } from "./add.js";
4
+ import { addBlocks, diffBlock } from "./add.js";
5
5
  import { CONFIG_FILE, defaultConfig, readConfig, writeConfig } from "./project.js";
6
6
  import { fetchIndex } from "./registry.js";
7
7
  const HELP = `redclove: add tested backend blocks to your project
@@ -51,10 +51,12 @@ async function main() {
51
51
  ? async (path) => /^y/i.test(await rl.question(`${path} has changes of yours. Replace it? (y/N) `))
52
52
  : "skip";
53
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
- }
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);
58
60
  }
59
61
  finally {
60
62
  rl?.close();
@@ -95,6 +97,7 @@ async function main() {
95
97
  }
96
98
  }
97
99
  function report(r) {
100
+ console.log(bold(`\n${r.slug}`) + (r.requiredBy ? dim(` added because ${r.requiredBy} needs it`) : ""));
98
101
  for (const f of r.written)
99
102
  console.log(` + ${f}`);
100
103
  for (const f of r.unchanged)
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { addBlock, diffBlock, BlockNotFoundError, type AddOptions, type AddResult, type ConflictPolicy, type DiffResult } from "./add.js";
1
+ export { addBlock, addBlocks, diffBlock, missingPackages, BlockNotFoundError, type AddOptions, type AddResult, type ConflictPolicy, type DiffResult } from "./add.js";
2
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";
3
+ export { readConfig, writeConfig, defaultConfig, detectPackageManager, addCommand, CONFIG_FILE, type Config } from "./project.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { addBlock, diffBlock, BlockNotFoundError } from "./add.js";
1
+ export { addBlock, addBlocks, diffBlock, missingPackages, BlockNotFoundError } from "./add.js";
2
2
  export { fetchBlock, fetchIndex, registryUrl, DEFAULT_REGISTRY } from "./registry.js";
3
- export { readConfig, writeConfig, defaultConfig, detectPackageManager, CONFIG_FILE } from "./project.js";
3
+ export { readConfig, writeConfig, defaultConfig, detectPackageManager, addCommand, CONFIG_FILE } from "./project.js";
@@ -5,6 +5,27 @@ export type IndexEntry = {
5
5
  category: string;
6
6
  summary: string;
7
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;
8
29
  };
9
30
  export type EnvVar = {
10
31
  name: string;
@@ -28,6 +49,8 @@ export type RegistryBlock = {
28
49
  devDependencies: string[];
29
50
  env: EnvVar[];
30
51
  usage: string;
52
+ /** Other blocks this one imports; they are added first. Absent in registries built before 0.1.1. */
53
+ requires?: string[];
31
54
  };
32
55
  /** The registry to read from: REDCLOVE_REGISTRY if set, otherwise the public one. It may be a URL or a local folder. */
33
56
  export declare const registryUrl: (override?: string) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redclove",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Add tested backend blocks (auth, payments, uploads…) to your project: npx redclove add auth",
5
5
  "license": "MIT",
6
6
  "type": "module",