rahman-resources 0.13.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +39 -35
- package/lib/manifest.json +647 -41
- package/package.json +5 -3
- package/bin/scan-consumers.mjs +0 -301
- package/lib/consumer-manifest.d.ts +0 -100
- package/lib/consumer-manifest.mjs +0 -216
- package/lib/consumer-manifest.test.mjs +0 -253
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rahman-resources",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Rahman Resources (rr) — shadcn-style installer for vertical slices. `npx resources add <slug>` copies slice into your project's `slices/<slug>/`. You own the files.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Rahman <casadezian@gmail.com>",
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"directory": "packages/cli"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"rahman-resources": "bin/cli.js"
|
|
15
|
+
"rahman-resources": "bin/cli.js",
|
|
16
|
+
"resources": "bin/cli.js",
|
|
17
|
+
"rr": "bin/cli.js"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
18
20
|
"bin",
|
package/bin/scan-consumers.mjs
DELETED
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
// Wave N+3 — `rr scan-consumers` CLI.
|
|
2
|
-
//
|
|
3
|
-
// Walks one or more consumer repos, reads each slice's `.kitab.json`, and
|
|
4
|
-
// diffs against the kitab's `frontend/slices/<slug>/slice.contract.ts`
|
|
5
|
-
// version. Prints a human ASCII table or `--json` for CI / MCP wiring.
|
|
6
|
-
//
|
|
7
|
-
// Usage:
|
|
8
|
-
// npx rahman-resources scan-consumers --path /home/rahman/projects/CareerPack
|
|
9
|
-
// npx rahman-resources scan-consumers --all
|
|
10
|
-
// npx rahman-resources scan-consumers --consumer careerpack --json
|
|
11
|
-
|
|
12
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
13
|
-
import { existsSync } from "node:fs";
|
|
14
|
-
import { join, resolve, dirname } from "node:path";
|
|
15
|
-
import { fileURLToPath } from "node:url";
|
|
16
|
-
import {
|
|
17
|
-
walkConsumerSlices,
|
|
18
|
-
diffSlice,
|
|
19
|
-
} from "../lib/consumer-manifest.mjs";
|
|
20
|
-
|
|
21
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
-
const __dirname = dirname(__filename);
|
|
23
|
-
|
|
24
|
-
// Kitab repo root resolved relative to this file's location: bin/ → cli → packages → repo
|
|
25
|
-
const KITAB_ROOT = resolve(__dirname, "..", "..", "..");
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Default consumer registry. Edit when adding new consumers. Paths are
|
|
29
|
-
* absolute on the operator workstation; CI invocation overrides via --path.
|
|
30
|
-
*/
|
|
31
|
-
export const DEFAULT_CONSUMERS = [
|
|
32
|
-
{ name: "careerpack", path: "/home/rahman/projects/CareerPack" },
|
|
33
|
-
{ name: "notion", path: "/home/rahman/projects/notion-page-clone" },
|
|
34
|
-
{ name: "rahmanef", path: "/home/rahman/projects/rahmanef.com" },
|
|
35
|
-
{ name: "content", path: "/home/rahman/projects/content-rahmanef-com" },
|
|
36
|
-
{ name: "superspace", path: "/home/rahman/projects/superspace" },
|
|
37
|
-
{ name: "cescadesigns", path: "/home/rahman/projects/cescadesigns" },
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const COLOR = {
|
|
41
|
-
reset: "\x1b[0m",
|
|
42
|
-
red: "\x1b[31m",
|
|
43
|
-
green: "\x1b[32m",
|
|
44
|
-
yellow: "\x1b[33m",
|
|
45
|
-
cyan: "\x1b[36m",
|
|
46
|
-
dim: "\x1b[2m",
|
|
47
|
-
blue: "\x1b[34m",
|
|
48
|
-
};
|
|
49
|
-
function color(c, str, jsonMode) {
|
|
50
|
-
return process.stdout.isTTY && !jsonMode ? `${COLOR[c]}${str}${COLOR.reset}` : str;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const ID_RE = /\bid\s*:\s*["']([a-z][a-z0-9-]*)["']/;
|
|
54
|
-
const VERSION_RE = /\bversion\s*:\s*["'](\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)["']/;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Read all kitab contracts as {slug → version} via regex extraction.
|
|
58
|
-
* Skips folders without a contract file (drift scanner separately reports
|
|
59
|
-
* those — we only need version data here).
|
|
60
|
-
*/
|
|
61
|
-
async function readKitabContractVersions() {
|
|
62
|
-
const slicesDir = join(KITAB_ROOT, "frontend", "slices");
|
|
63
|
-
const entries = await readdir(slicesDir, { withFileTypes: true });
|
|
64
|
-
const out = new Map();
|
|
65
|
-
for (const e of entries) {
|
|
66
|
-
if (!e.isDirectory() || e.name.startsWith("_")) continue;
|
|
67
|
-
const contractPath = join(slicesDir, e.name, "slice.contract.ts");
|
|
68
|
-
if (!existsSync(contractPath)) continue;
|
|
69
|
-
try {
|
|
70
|
-
const src = await readFile(contractPath, "utf8");
|
|
71
|
-
const idMatch = src.match(ID_RE);
|
|
72
|
-
const verMatch = src.match(VERSION_RE);
|
|
73
|
-
if (!idMatch || !verMatch) continue;
|
|
74
|
-
out.set(idMatch[1], verMatch[1]);
|
|
75
|
-
} catch {
|
|
76
|
-
// skip
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function parseArgs(argv) {
|
|
83
|
-
const args = { paths: [], consumers: [], all: false, json: false, help: false };
|
|
84
|
-
for (let i = 0; i < argv.length; i++) {
|
|
85
|
-
const a = argv[i];
|
|
86
|
-
if (a === "--path") {
|
|
87
|
-
args.paths.push(argv[++i]);
|
|
88
|
-
} else if (a === "--consumer") {
|
|
89
|
-
args.consumers.push(argv[++i]);
|
|
90
|
-
} else if (a === "--all") {
|
|
91
|
-
args.all = true;
|
|
92
|
-
} else if (a === "--json") {
|
|
93
|
-
args.json = true;
|
|
94
|
-
} else if (a === "-h" || a === "--help") {
|
|
95
|
-
args.help = true;
|
|
96
|
-
} else if (a.startsWith("--")) {
|
|
97
|
-
throw new Error(`unknown flag: ${a}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return args;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function printHelp() {
|
|
104
|
-
console.log(`
|
|
105
|
-
Usage: rahman-resources scan-consumers [options]
|
|
106
|
-
|
|
107
|
-
Detect bidirectional sync state between the kitab and consumer repos by
|
|
108
|
-
reading each consumer slice's .kitab.json and diffing vs the kitab contract
|
|
109
|
-
version.
|
|
110
|
-
|
|
111
|
-
Options:
|
|
112
|
-
--path <dir> Scan one consumer repo at this path. Repeatable.
|
|
113
|
-
--consumer <name> Scan a registered consumer by name (see DEFAULT_CONSUMERS).
|
|
114
|
-
Repeatable.
|
|
115
|
-
--all Scan every registered consumer.
|
|
116
|
-
--json Machine-readable JSON output.
|
|
117
|
-
-h, --help Print this help.
|
|
118
|
-
|
|
119
|
-
Default if no flag given: --all.
|
|
120
|
-
|
|
121
|
-
Exit code:
|
|
122
|
-
0 no UP-sync needed (DOWN-sync surfaced as info, not error)
|
|
123
|
-
1 at least one consumer slice is up-needed/diverged AND policy != frozen
|
|
124
|
-
2 malformed manifest in some consumer (also exits 1)
|
|
125
|
-
`);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function resolveTargets(args) {
|
|
129
|
-
const targets = [];
|
|
130
|
-
for (const p of args.paths) {
|
|
131
|
-
targets.push({ name: p, path: resolve(p) });
|
|
132
|
-
}
|
|
133
|
-
for (const cname of args.consumers) {
|
|
134
|
-
const found = DEFAULT_CONSUMERS.find((c) => c.name === cname);
|
|
135
|
-
if (!found) {
|
|
136
|
-
throw new Error(`unknown consumer "${cname}" — registered: ${DEFAULT_CONSUMERS.map((c) => c.name).join(", ")}`);
|
|
137
|
-
}
|
|
138
|
-
targets.push({ name: found.name, path: found.path });
|
|
139
|
-
}
|
|
140
|
-
if (args.all || (targets.length === 0 && !args.help)) {
|
|
141
|
-
for (const c of DEFAULT_CONSUMERS) {
|
|
142
|
-
if (!targets.find((t) => t.path === c.path)) targets.push(c);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return targets;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function verdictTone(v) {
|
|
149
|
-
switch (v) {
|
|
150
|
-
case "in-sync":
|
|
151
|
-
return "green";
|
|
152
|
-
case "up-needed":
|
|
153
|
-
return "blue";
|
|
154
|
-
case "down-needed":
|
|
155
|
-
return "yellow";
|
|
156
|
-
case "diverged":
|
|
157
|
-
return "red";
|
|
158
|
-
case "consumer-only":
|
|
159
|
-
return "cyan";
|
|
160
|
-
case "kitab-only":
|
|
161
|
-
return "dim";
|
|
162
|
-
default:
|
|
163
|
-
return "reset";
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function pad(s, w) {
|
|
168
|
-
const str = String(s);
|
|
169
|
-
if (str.length >= w) return str;
|
|
170
|
-
return str + " ".repeat(w - str.length);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function scanOne(target, kitabVersions) {
|
|
174
|
-
if (!existsSync(target.path)) {
|
|
175
|
-
return { name: target.name, path: target.path, error: "path does not exist" };
|
|
176
|
-
}
|
|
177
|
-
const walked = await walkConsumerSlices(target.path);
|
|
178
|
-
const diffs = [];
|
|
179
|
-
const errors = [];
|
|
180
|
-
const seen = new Set();
|
|
181
|
-
for (const w of walked) {
|
|
182
|
-
if (w.error) {
|
|
183
|
-
errors.push({ dir: w.dir, message: w.error });
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
const slug = w.manifest.kitabSlug;
|
|
187
|
-
seen.add(slug);
|
|
188
|
-
const kv = kitabVersions.get(slug) ?? null;
|
|
189
|
-
diffs.push(diffSlice({ slug, manifest: w.manifest, kitabVersion: kv }));
|
|
190
|
-
}
|
|
191
|
-
// Surface kitab-only slices the consumer hasn't adopted at all.
|
|
192
|
-
for (const [slug, version] of kitabVersions) {
|
|
193
|
-
if (!seen.has(slug)) {
|
|
194
|
-
diffs.push(diffSlice({ slug, manifest: null, kitabVersion: version }));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
diffs.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
198
|
-
return { name: target.name, path: target.path, diffs, errors };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export async function runScan(argv = []) {
|
|
202
|
-
let args;
|
|
203
|
-
try {
|
|
204
|
-
args = parseArgs(argv);
|
|
205
|
-
} catch (err) {
|
|
206
|
-
console.error(color("red", err.message, false));
|
|
207
|
-
process.exit(2);
|
|
208
|
-
}
|
|
209
|
-
if (args.help) {
|
|
210
|
-
printHelp();
|
|
211
|
-
process.exit(0);
|
|
212
|
-
}
|
|
213
|
-
const targets = resolveTargets(args);
|
|
214
|
-
const kitabVersions = await readKitabContractVersions();
|
|
215
|
-
const reports = await Promise.all(targets.map((t) => scanOne(t, kitabVersions)));
|
|
216
|
-
|
|
217
|
-
let upNeeded = 0;
|
|
218
|
-
let parseErrors = 0;
|
|
219
|
-
for (const r of reports) {
|
|
220
|
-
if (r.error) continue;
|
|
221
|
-
parseErrors += r.errors?.length ?? 0;
|
|
222
|
-
for (const d of r.diffs ?? []) {
|
|
223
|
-
if (d.direction === "up-needed" || d.direction === "diverged") upNeeded++;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (args.json) {
|
|
228
|
-
process.stdout.write(
|
|
229
|
-
JSON.stringify(
|
|
230
|
-
{
|
|
231
|
-
kitabRoot: KITAB_ROOT,
|
|
232
|
-
kitabContracts: kitabVersions.size,
|
|
233
|
-
targets: reports,
|
|
234
|
-
upNeeded,
|
|
235
|
-
parseErrors,
|
|
236
|
-
},
|
|
237
|
-
null,
|
|
238
|
-
2,
|
|
239
|
-
),
|
|
240
|
-
);
|
|
241
|
-
process.exit(upNeeded > 0 || parseErrors > 0 ? 1 : 0);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
console.log(color("cyan", "\n== consumer sync scan ==", false));
|
|
245
|
-
console.log(color("dim", `kitab root: ${KITAB_ROOT} (${kitabVersions.size} contracts)`, false));
|
|
246
|
-
for (const r of reports) {
|
|
247
|
-
console.log("");
|
|
248
|
-
console.log(color("cyan", `▸ ${r.name}`, false), color("dim", r.path, false));
|
|
249
|
-
if (r.error) {
|
|
250
|
-
console.log(" " + color("red", `error: ${r.error}`, false));
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (r.errors?.length) {
|
|
254
|
-
for (const e of r.errors) {
|
|
255
|
-
console.log(" " + color("red", `parse error @ ${e.dir}: ${e.message}`, false));
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
if (r.diffs.length === 0) {
|
|
259
|
-
console.log(" " + color("dim", "(no slices found)", false));
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
const headers = ["slug", "kitab", "consumer", "verdict", "actions"];
|
|
263
|
-
const rows = r.diffs.map((d) => [
|
|
264
|
-
d.slug,
|
|
265
|
-
d.kitabVersion ?? "—",
|
|
266
|
-
d.consumerVersion ?? "—",
|
|
267
|
-
d.direction,
|
|
268
|
-
d.allowedActions.length ? d.allowedActions.join(",") : "—",
|
|
269
|
-
]);
|
|
270
|
-
const widths = headers.map((h, i) =>
|
|
271
|
-
Math.max(h.length, ...rows.map((r) => String(r[i]).length)),
|
|
272
|
-
);
|
|
273
|
-
console.log(" " + headers.map((h, i) => pad(h, widths[i])).join(" "));
|
|
274
|
-
console.log(" " + widths.map((w) => "-".repeat(w)).join(" "));
|
|
275
|
-
for (let i = 0; i < rows.length; i++) {
|
|
276
|
-
const d = r.diffs[i];
|
|
277
|
-
const tone = verdictTone(d.direction);
|
|
278
|
-
const cells = rows[i].map((c, ci) => pad(c, widths[ci]));
|
|
279
|
-
cells[3] = color(tone, cells[3], false);
|
|
280
|
-
console.log(" " + cells.join(" "));
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
console.log("");
|
|
284
|
-
console.log(
|
|
285
|
-
color("cyan", "Summary:", false),
|
|
286
|
-
`${reports.length} consumer(s) · ` +
|
|
287
|
-
color(upNeeded > 0 ? "blue" : "green", `${upNeeded} up-needed/diverged`, false) +
|
|
288
|
-
` · ` +
|
|
289
|
-
color(parseErrors > 0 ? "red" : "green", `${parseErrors} parse error(s)`, false),
|
|
290
|
-
);
|
|
291
|
-
console.log("");
|
|
292
|
-
process.exit(upNeeded > 0 || parseErrors > 0 ? 1 : 0);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__filename);
|
|
296
|
-
if (isMain) {
|
|
297
|
-
runScan(process.argv.slice(2)).catch((err) => {
|
|
298
|
-
console.error(`scan-consumers crashed: ${err.stack || err}`);
|
|
299
|
-
process.exit(2);
|
|
300
|
-
});
|
|
301
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wave N+3 — Bidirectional Sync Detection Layer (BSDL).
|
|
3
|
-
*
|
|
4
|
-
* Consumer-side manifest contract: every slice copy in a consumer repo
|
|
5
|
-
* (CareerPack / notion / rahmanef / content / superspace / cescadesigns)
|
|
6
|
-
* declares which kitab slug + version it adopted, what the consumer-local
|
|
7
|
-
* version is, what sync direction is allowed, and how generalised the
|
|
8
|
-
* consumer-local copy is.
|
|
9
|
-
*
|
|
10
|
-
* The kitab `rr scan-consumers` command reads these and surfaces what
|
|
11
|
-
* needs UP-sync (`rr-send`) vs DOWN-sync (`rr update`).
|
|
12
|
-
*
|
|
13
|
-
* @module packages/cli/lib/consumer-manifest
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
export type SyncDirection =
|
|
17
|
-
| "bidirectional"
|
|
18
|
-
| "down-only"
|
|
19
|
-
| "up-only"
|
|
20
|
-
| "frozen";
|
|
21
|
-
|
|
22
|
-
export type GeneralizationStatus =
|
|
23
|
-
| "portable"
|
|
24
|
-
| "needs-adapter"
|
|
25
|
-
| "consumer-locked";
|
|
26
|
-
|
|
27
|
-
export type SyncDirectionVerdict =
|
|
28
|
-
| "in-sync"
|
|
29
|
-
| "up-needed"
|
|
30
|
-
| "down-needed"
|
|
31
|
-
| "diverged"
|
|
32
|
-
| "consumer-only"
|
|
33
|
-
| "kitab-only";
|
|
34
|
-
|
|
35
|
-
export interface ConsumerGeneralization {
|
|
36
|
-
/**
|
|
37
|
-
* Portable: the slice is fully generic and `rr-send` will accept it.
|
|
38
|
-
* needs-adapter: requires a thin adapter to plug consumer-specific
|
|
39
|
-
* props/labels/routes — kitab can ingest if blockers are addressed.
|
|
40
|
-
* consumer-locked: contains business-specific logic that cannot be
|
|
41
|
-
* generalised — UP-sync rejected; only DOWN-sync allowed.
|
|
42
|
-
*/
|
|
43
|
-
status: GeneralizationStatus;
|
|
44
|
-
/** ISO date — when the audit ran. */
|
|
45
|
-
auditedAt: string;
|
|
46
|
-
/** Human-readable reasons preventing portability. Empty when portable. */
|
|
47
|
-
blockers: string[];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface ConsumerManifest {
|
|
51
|
-
$schema?: string;
|
|
52
|
-
/** Kebab-case slug — must match a kitab `slice.contract.ts` `id`. */
|
|
53
|
-
kitabSlug: string;
|
|
54
|
-
/** Semver of the kitab version this copy was last pulled from. */
|
|
55
|
-
kitabVersion: string;
|
|
56
|
-
/** Semver of the consumer-local divergence. Bump after each local edit. */
|
|
57
|
-
consumerVersion: string;
|
|
58
|
-
syncDirection: SyncDirection;
|
|
59
|
-
generalization: ConsumerGeneralization;
|
|
60
|
-
/** ISO timestamp of last successful `rr update --apply`. */
|
|
61
|
-
lastPullAt: string | null;
|
|
62
|
-
/** ISO timestamp of last successful `/rr-send`. */
|
|
63
|
-
lastPushAt: string | null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface SyncDiff {
|
|
67
|
-
slug: string;
|
|
68
|
-
/** Kitab contract version, or null if slice not in kitab. */
|
|
69
|
-
kitabVersion: string | null;
|
|
70
|
-
/** Consumer manifest version, or null if no manifest in consumer. */
|
|
71
|
-
consumerVersion: string | null;
|
|
72
|
-
direction: SyncDirectionVerdict;
|
|
73
|
-
blockers: string[];
|
|
74
|
-
generalization: GeneralizationStatus | null;
|
|
75
|
-
/** Allowed sync directions per the manifest, narrowed by verdict. */
|
|
76
|
-
allowedActions: ("rr-send" | "rr-update")[];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface WalkedSlice {
|
|
80
|
-
/** Absolute path to the slice dir inside the consumer repo. */
|
|
81
|
-
dir: string;
|
|
82
|
-
/** Parsed manifest, or undefined if read failed. */
|
|
83
|
-
manifest?: ConsumerManifest;
|
|
84
|
-
/** Error message if manifest existed but failed validation. */
|
|
85
|
-
error?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function validateConsumerManifest(m: unknown): string[];
|
|
89
|
-
export function readConsumerManifest(filepath: string): Promise<ConsumerManifest>;
|
|
90
|
-
export function writeConsumerManifest(
|
|
91
|
-
filepath: string,
|
|
92
|
-
m: ConsumerManifest,
|
|
93
|
-
): Promise<void>;
|
|
94
|
-
export function diffSlice(input: {
|
|
95
|
-
slug: string;
|
|
96
|
-
manifest: ConsumerManifest | null;
|
|
97
|
-
kitabVersion: string | null;
|
|
98
|
-
}): SyncDiff;
|
|
99
|
-
export function walkConsumerSlices(consumerRoot: string): Promise<WalkedSlice[]>;
|
|
100
|
-
export function compareSemver(a: string, b: string): number;
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
// Wave N+3 — Bidirectional Sync Detection Layer (BSDL).
|
|
2
|
-
//
|
|
3
|
-
// Consumer-side `.kitab.json` schema, reader/writer, semver compare, and the
|
|
4
|
-
// per-slice sync diff used by `rr scan-consumers`. See d.ts for the full type
|
|
5
|
-
// vocabulary and docs/consumer-manifest.md for the design rationale.
|
|
6
|
-
|
|
7
|
-
import { readFile, writeFile, readdir } from "node:fs/promises";
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
|
|
11
|
-
const KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
12
|
-
const SEMVER_RE =
|
|
13
|
-
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
14
|
-
const ISO_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))?$/;
|
|
15
|
-
|
|
16
|
-
const SYNC_DIRECTIONS = new Set([
|
|
17
|
-
"bidirectional",
|
|
18
|
-
"down-only",
|
|
19
|
-
"up-only",
|
|
20
|
-
"frozen",
|
|
21
|
-
]);
|
|
22
|
-
const GENERALIZATION_STATUS = new Set([
|
|
23
|
-
"portable",
|
|
24
|
-
"needs-adapter",
|
|
25
|
-
"consumer-locked",
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
export function validateConsumerManifest(m) {
|
|
29
|
-
const errors = [];
|
|
30
|
-
if (!m || typeof m !== "object") {
|
|
31
|
-
errors.push("manifest must be a non-null object");
|
|
32
|
-
return errors;
|
|
33
|
-
}
|
|
34
|
-
if (typeof m.kitabSlug !== "string" || !KEBAB_RE.test(m.kitabSlug)) {
|
|
35
|
-
errors.push(`kitabSlug "${String(m.kitabSlug)}" must be kebab-case`);
|
|
36
|
-
}
|
|
37
|
-
if (typeof m.kitabVersion !== "string" || !SEMVER_RE.test(m.kitabVersion)) {
|
|
38
|
-
errors.push(`kitabVersion "${String(m.kitabVersion)}" must be semver`);
|
|
39
|
-
}
|
|
40
|
-
if (
|
|
41
|
-
typeof m.consumerVersion !== "string" ||
|
|
42
|
-
!SEMVER_RE.test(m.consumerVersion)
|
|
43
|
-
) {
|
|
44
|
-
errors.push(
|
|
45
|
-
`consumerVersion "${String(m.consumerVersion)}" must be semver`,
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
if (!SYNC_DIRECTIONS.has(m.syncDirection)) {
|
|
49
|
-
errors.push(
|
|
50
|
-
`syncDirection "${String(m.syncDirection)}" must be one of bidirectional|down-only|up-only|frozen`,
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
if (!m.generalization || typeof m.generalization !== "object") {
|
|
54
|
-
errors.push("generalization must be an object");
|
|
55
|
-
} else {
|
|
56
|
-
const g = m.generalization;
|
|
57
|
-
if (!GENERALIZATION_STATUS.has(g.status)) {
|
|
58
|
-
errors.push(
|
|
59
|
-
`generalization.status "${String(g.status)}" must be portable|needs-adapter|consumer-locked`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
if (typeof g.auditedAt !== "string" || !ISO_RE.test(g.auditedAt)) {
|
|
63
|
-
errors.push(
|
|
64
|
-
`generalization.auditedAt "${String(g.auditedAt)}" must be ISO date`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
if (!Array.isArray(g.blockers)) {
|
|
68
|
-
errors.push("generalization.blockers must be an array of strings");
|
|
69
|
-
} else {
|
|
70
|
-
for (const b of g.blockers) {
|
|
71
|
-
if (typeof b !== "string") {
|
|
72
|
-
errors.push(`generalization.blockers entry must be string, got ${typeof b}`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
if (g.status !== "portable" && (!Array.isArray(g.blockers) || g.blockers.length === 0)) {
|
|
77
|
-
errors.push(
|
|
78
|
-
`generalization.status "${g.status}" requires at least one entry in blockers[]`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (m.lastPullAt !== null && (typeof m.lastPullAt !== "string" || !ISO_RE.test(m.lastPullAt))) {
|
|
83
|
-
errors.push(`lastPullAt must be null or ISO timestamp`);
|
|
84
|
-
}
|
|
85
|
-
if (m.lastPushAt !== null && (typeof m.lastPushAt !== "string" || !ISO_RE.test(m.lastPushAt))) {
|
|
86
|
-
errors.push(`lastPushAt must be null or ISO timestamp`);
|
|
87
|
-
}
|
|
88
|
-
return errors;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export async function readConsumerManifest(filepath) {
|
|
92
|
-
const raw = await readFile(filepath, "utf8");
|
|
93
|
-
let m;
|
|
94
|
-
try {
|
|
95
|
-
m = JSON.parse(raw);
|
|
96
|
-
} catch (err) {
|
|
97
|
-
throw new Error(`${filepath}: invalid JSON — ${err.message}`);
|
|
98
|
-
}
|
|
99
|
-
const errors = validateConsumerManifest(m);
|
|
100
|
-
if (errors.length > 0) {
|
|
101
|
-
throw new Error(`${filepath}: ${errors.join("; ")}`);
|
|
102
|
-
}
|
|
103
|
-
return m;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export async function writeConsumerManifest(filepath, m) {
|
|
107
|
-
const errors = validateConsumerManifest(m);
|
|
108
|
-
if (errors.length > 0) {
|
|
109
|
-
throw new Error(`refusing to write invalid manifest: ${errors.join("; ")}`);
|
|
110
|
-
}
|
|
111
|
-
const ordered = {
|
|
112
|
-
$schema: m.$schema ?? "https://resource.rahmanef.com/schemas/kitab-consumer.json",
|
|
113
|
-
kitabSlug: m.kitabSlug,
|
|
114
|
-
kitabVersion: m.kitabVersion,
|
|
115
|
-
consumerVersion: m.consumerVersion,
|
|
116
|
-
syncDirection: m.syncDirection,
|
|
117
|
-
generalization: m.generalization,
|
|
118
|
-
lastPullAt: m.lastPullAt,
|
|
119
|
-
lastPushAt: m.lastPushAt,
|
|
120
|
-
};
|
|
121
|
-
await writeFile(filepath, JSON.stringify(ordered, null, 2) + "\n", "utf8");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Compare two semvers. Returns -1, 0, or 1. Pre-release tags ignored
|
|
126
|
-
* (compared on MAJOR.MINOR.PATCH only — sufficient for sync direction
|
|
127
|
-
* decisions; consumers shouldn't be depending on pre-release ordering for
|
|
128
|
-
* cross-repo sync gates).
|
|
129
|
-
*/
|
|
130
|
-
export function compareSemver(a, b) {
|
|
131
|
-
const pa = String(a).split(/[.+-]/).slice(0, 3).map((n) => parseInt(n, 10));
|
|
132
|
-
const pb = String(b).split(/[.+-]/).slice(0, 3).map((n) => parseInt(n, 10));
|
|
133
|
-
for (let i = 0; i < 3; i++) {
|
|
134
|
-
const av = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
135
|
-
const bv = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
136
|
-
if (av !== bv) return av < bv ? -1 : 1;
|
|
137
|
-
}
|
|
138
|
-
return 0;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function allowedActionsFor(verdict, manifest) {
|
|
142
|
-
if (!manifest) return [];
|
|
143
|
-
const dir = manifest.syncDirection;
|
|
144
|
-
if (dir === "frozen") return [];
|
|
145
|
-
const actions = [];
|
|
146
|
-
if ((verdict === "up-needed" || verdict === "diverged") && (dir === "bidirectional" || dir === "up-only")) {
|
|
147
|
-
if (manifest.generalization.status === "portable") actions.push("rr-send");
|
|
148
|
-
}
|
|
149
|
-
if ((verdict === "down-needed" || verdict === "diverged") && (dir === "bidirectional" || dir === "down-only")) {
|
|
150
|
-
actions.push("rr-update");
|
|
151
|
-
}
|
|
152
|
-
return actions;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function diffSlice({ slug, manifest, kitabVersion }) {
|
|
156
|
-
if (manifest && !kitabVersion) {
|
|
157
|
-
return {
|
|
158
|
-
slug,
|
|
159
|
-
kitabVersion: null,
|
|
160
|
-
consumerVersion: manifest.consumerVersion,
|
|
161
|
-
direction: "consumer-only",
|
|
162
|
-
blockers: manifest.generalization.blockers,
|
|
163
|
-
generalization: manifest.generalization.status,
|
|
164
|
-
allowedActions: manifest.generalization.status === "portable" ? ["rr-send"] : [],
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
if (!manifest && kitabVersion) {
|
|
168
|
-
return {
|
|
169
|
-
slug,
|
|
170
|
-
kitabVersion,
|
|
171
|
-
consumerVersion: null,
|
|
172
|
-
direction: "kitab-only",
|
|
173
|
-
blockers: [],
|
|
174
|
-
generalization: null,
|
|
175
|
-
allowedActions: ["rr-update"],
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
if (!manifest) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
const consumerCmpKitab = compareSemver(manifest.consumerVersion, manifest.kitabVersion);
|
|
182
|
-
const kitabCmpAdopted = compareSemver(kitabVersion, manifest.kitabVersion);
|
|
183
|
-
let direction;
|
|
184
|
-
if (consumerCmpKitab > 0 && kitabCmpAdopted > 0) direction = "diverged";
|
|
185
|
-
else if (consumerCmpKitab > 0) direction = "up-needed";
|
|
186
|
-
else if (kitabCmpAdopted > 0) direction = "down-needed";
|
|
187
|
-
else direction = "in-sync";
|
|
188
|
-
return {
|
|
189
|
-
slug,
|
|
190
|
-
kitabVersion,
|
|
191
|
-
consumerVersion: manifest.consumerVersion,
|
|
192
|
-
direction,
|
|
193
|
-
blockers: manifest.generalization.blockers,
|
|
194
|
-
generalization: manifest.generalization.status,
|
|
195
|
-
allowedActions: allowedActionsFor(direction, manifest),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export async function walkConsumerSlices(consumerRoot) {
|
|
200
|
-
const slicesDir = join(consumerRoot, "frontend", "slices");
|
|
201
|
-
if (!existsSync(slicesDir)) return [];
|
|
202
|
-
const out = [];
|
|
203
|
-
const entries = await readdir(slicesDir, { withFileTypes: true });
|
|
204
|
-
for (const e of entries) {
|
|
205
|
-
if (!e.isDirectory() || e.name.startsWith("_")) continue;
|
|
206
|
-
const manifestPath = join(slicesDir, e.name, ".kitab.json");
|
|
207
|
-
if (!existsSync(manifestPath)) continue;
|
|
208
|
-
try {
|
|
209
|
-
const m = await readConsumerManifest(manifestPath);
|
|
210
|
-
out.push({ dir: join(slicesDir, e.name), manifest: m });
|
|
211
|
-
} catch (err) {
|
|
212
|
-
out.push({ dir: join(slicesDir, e.name), error: err.message });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return out;
|
|
216
|
-
}
|