@thehumanpatternlab/hpl 0.0.1-alpha.5

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.
Files changed (49) hide show
  1. package/README.md +42 -0
  2. package/dist/__tests__/config.test.js +22 -0
  3. package/dist/__tests__/outputContract.test.js +58 -0
  4. package/dist/bin/hpl.js +158 -0
  5. package/dist/cli/output.js +21 -0
  6. package/dist/cli/outputContract.js +32 -0
  7. package/dist/commands/notesSync.js +191 -0
  8. package/dist/commands/publish.js +36 -0
  9. package/dist/index.js +36 -0
  10. package/dist/lab.js +10 -0
  11. package/dist/lib/config.js +52 -0
  12. package/dist/lib/http.js +18 -0
  13. package/dist/lib/notes.js +62 -0
  14. package/dist/lib/output.js +21 -0
  15. package/dist/sdk/LabClient.js +94 -0
  16. package/dist/src/__tests__/config.test.js +22 -0
  17. package/dist/src/__tests__/outputContract.test.js +58 -0
  18. package/dist/src/cli/output.js +21 -0
  19. package/dist/src/cli/outputContract.js +32 -0
  20. package/dist/src/commands/capabilities.js +10 -0
  21. package/dist/src/commands/health.js +48 -0
  22. package/dist/src/commands/notes/get.js +48 -0
  23. package/dist/src/commands/notes/list.js +43 -0
  24. package/dist/src/commands/notesSync.js +191 -0
  25. package/dist/src/commands/version.js +12 -0
  26. package/dist/src/config.js +10 -0
  27. package/dist/src/contract/capabilities.js +15 -0
  28. package/dist/src/contract/envelope.js +16 -0
  29. package/dist/src/contract/exitCodes.js +21 -0
  30. package/dist/src/contract/intents.js +49 -0
  31. package/dist/src/contract/schema.js +59 -0
  32. package/dist/src/http/client.js +39 -0
  33. package/dist/src/index.js +36 -0
  34. package/dist/src/io.js +15 -0
  35. package/dist/src/lib/config.js +52 -0
  36. package/dist/src/lib/http.js +18 -0
  37. package/dist/src/lib/notes.js +62 -0
  38. package/dist/src/render/table.js +17 -0
  39. package/dist/src/render/text.js +40 -0
  40. package/dist/src/sdk/ApiError.js +10 -0
  41. package/dist/src/sdk/LabClient.js +101 -0
  42. package/dist/src/sync/summary.js +25 -0
  43. package/dist/src/sync/types.js +2 -0
  44. package/dist/src/types/labNotes.js +27 -0
  45. package/dist/src/utils/loadMarkdown.js +10 -0
  46. package/dist/sync/summary.js +25 -0
  47. package/dist/sync/types.js +2 -0
  48. package/dist/utils/loadMarkdown.js +10 -0
  49. package/package.json +70 -0
@@ -0,0 +1,10 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — CLI CONFIG
3
+ -----------------------------------------------------------
4
+ Purpose: Single source of truth for config resolution.
5
+ Contract: Deterministic defaults.
6
+ =========================================================== */
7
+ export function getConfig() {
8
+ const apiBaseUrl = (process.env.HPL_API_BASE_URL || "https://api.thehumanpatternlab.com").replace(/\/+$/, "");
9
+ return { apiBaseUrl };
10
+ }
@@ -0,0 +1,15 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — CLI CAPABILITIES (Alpha Tier)
3
+ -----------------------------------------------------------
4
+ Purpose: Capability disclosure for AI agents (no assumptions).
5
+ Contract: show_capabilities MUST emit tier, intents, schema versions.
6
+ =========================================================== */
7
+ import { CLI_SCHEMA_VERSION } from "./schema";
8
+ import { listAlphaIntents } from "./intents";
9
+ export function getCapabilitiesAlpha() {
10
+ return {
11
+ intentTier: "alpha",
12
+ supportedIntents: listAlphaIntents().map((i) => i.intent),
13
+ schemaVersions: [CLI_SCHEMA_VERSION],
14
+ };
15
+ }
@@ -0,0 +1,16 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — CLI ENVELOPE BUILDERS (Alpha Tier)
3
+ -----------------------------------------------------------
4
+ Purpose: Single source of truth for contract-compliant JSON output.
5
+ Guarantee: When --json, stdout emits JSON only (no logs).
6
+ =========================================================== */
7
+ import { CLI_SCHEMA_VERSION } from "./schema";
8
+ export function ok(command, intent, data) {
9
+ return { schemaVersion: CLI_SCHEMA_VERSION, command, status: "ok", intent, data };
10
+ }
11
+ export function warn(command, intent, warnings, data) {
12
+ return { schemaVersion: CLI_SCHEMA_VERSION, command, status: "warn", intent, warnings, data };
13
+ }
14
+ export function err(command, intent, error) {
15
+ return { schemaVersion: CLI_SCHEMA_VERSION, command, status: "error", intent, error };
16
+ }
@@ -0,0 +1,21 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — CLI EXIT CODES (Alpha Tier)
3
+ -----------------------------------------------------------
4
+ Purpose: Consistent meanings across commands (machine-parseable).
5
+ Contract: "Errors & Exit Codes" predictable and structured.
6
+ =========================================================== */
7
+ /**
8
+ * Exit code meanings are stable across commands.
9
+ * Additive only; do not repurpose existing numeric values.
10
+ */
11
+ export const EXIT = {
12
+ OK: 0,
13
+ USAGE: 2, // bad args / invalid flags
14
+ NOT_FOUND: 3, // 404 semantics
15
+ AUTH: 4, // auth required / invalid token
16
+ FORBIDDEN: 5, // insufficient scope/permission
17
+ NETWORK: 10, // DNS/timeout/unreachable
18
+ SERVER: 11, // 5xx or unexpected response
19
+ CONTRACT: 12, // schema mismatch / invalid JSON contract
20
+ UNKNOWN: 1,
21
+ };
@@ -0,0 +1,49 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — CLI INTENT REGISTRY (Alpha Tier)
3
+ -----------------------------------------------------------
4
+ Purpose: Machine-legible, stable intent identifiers for CLI commands.
5
+ Contract: Lab CLI MVP Contract v0.x (Intent Registry + Explicit Intent)
6
+ Notes:
7
+ - Intent IDs are snake_case and treated as contractual identifiers.
8
+ - Changing the meaning of an intent is breaking, even in v0.x.
9
+ =========================================================== */
10
+ /**
11
+ * Alpha Tier intents (MVP).
12
+ * Additive only. Do not change semantics of existing IDs.
13
+ */
14
+ export const INTENTS_ALPHA = {
15
+ show_version: {
16
+ intent: "show_version",
17
+ intentVersion: "1",
18
+ scope: ["cli"],
19
+ sideEffects: [],
20
+ reversible: true,
21
+ },
22
+ show_capabilities: {
23
+ intent: "show_capabilities",
24
+ intentVersion: "1",
25
+ scope: ["cli"],
26
+ sideEffects: [],
27
+ reversible: true,
28
+ },
29
+ check_health: {
30
+ intent: "check_health",
31
+ intentVersion: "1",
32
+ scope: ["remote_api"],
33
+ sideEffects: [],
34
+ reversible: true,
35
+ },
36
+ render_lab_note: {
37
+ intent: "render_lab_note",
38
+ intentVersion: "1",
39
+ scope: ["lab_notes", "remote_api"],
40
+ sideEffects: [],
41
+ reversible: true,
42
+ },
43
+ };
44
+ export function getAlphaIntent(id) {
45
+ return INTENTS_ALPHA[id];
46
+ }
47
+ export function listAlphaIntents() {
48
+ return Object.values(INTENTS_ALPHA);
49
+ }
@@ -0,0 +1,59 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — CLI OUTPUT SCHEMAS (Alpha Tier)
3
+ -----------------------------------------------------------
4
+ Purpose: Enforce the "JSON is a Contract" guarantee for --json mode.
5
+ Contract: Lab CLI MVP Contract v0.x (Schema Versioning + Determinism)
6
+ =========================================================== */
7
+ import { z } from "zod";
8
+ /** Global structured output schema version (CLI contract layer). */
9
+ export const CLI_SCHEMA_VERSION = "0.1";
10
+ export const StatusSchema = z.enum(["ok", "warn", "error"]);
11
+ export const IntentBlockSchema = z.object({
12
+ intent: z.string(),
13
+ intentVersion: z.literal("1"),
14
+ scope: z.array(z.string()),
15
+ sideEffects: z.array(z.string()),
16
+ reversible: z.boolean(),
17
+ });
18
+ /**
19
+ * Base envelope required by the contract for all --json outputs.
20
+ * Commands extend this with typed `data` for success, or `error` for failures.
21
+ */
22
+ export const BaseEnvelopeSchema = z.object({
23
+ schemaVersion: z.literal(CLI_SCHEMA_VERSION),
24
+ command: z.string(),
25
+ status: StatusSchema,
26
+ intent: IntentBlockSchema,
27
+ });
28
+ /** Standard error payload (machine-parseable). */
29
+ export const ErrorPayloadSchema = z.object({
30
+ code: z.string(), // stable error code (e.g., "E_NETWORK", "E_AUTH")
31
+ message: z.string(), // short human-readable
32
+ details: z.unknown().optional() // optional machine-parseable context
33
+ });
34
+ /**
35
+ * Success envelope: { ...base, data: T }
36
+ */
37
+ export function successSchema(dataSchema) {
38
+ return BaseEnvelopeSchema.extend({
39
+ status: z.literal("ok"),
40
+ data: dataSchema,
41
+ });
42
+ }
43
+ /**
44
+ * Warning envelope: { ...base, warnings: [...], data?: T }
45
+ */
46
+ export function warnSchema(dataSchema) {
47
+ const base = BaseEnvelopeSchema.extend({
48
+ status: z.literal("warn"),
49
+ warnings: z.array(z.string()).min(1),
50
+ });
51
+ return dataSchema ? base.extend({ data: dataSchema }) : base;
52
+ }
53
+ /**
54
+ * Error envelope: { ...base, error: { code, message, details? } }
55
+ */
56
+ export const ErrorEnvelopeSchema = BaseEnvelopeSchema.extend({
57
+ status: z.literal("error"),
58
+ error: ErrorPayloadSchema,
59
+ });
@@ -0,0 +1,39 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — HTTP CLIENT (minimal)
3
+ -----------------------------------------------------------
4
+ Purpose: Fetch wrapper with consistent error shaping.
5
+ Notes:
6
+ - Supports both raw API payloads and envelope form { ok: true, data: ... }.
7
+ =========================================================== */
8
+ import { getConfig } from "../config";
9
+ export class HttpError extends Error {
10
+ status;
11
+ body;
12
+ constructor(message, status, body) {
13
+ super(message);
14
+ this.name = "HttpError";
15
+ this.status = status;
16
+ this.body = body;
17
+ }
18
+ }
19
+ function unwrap(payload) {
20
+ if (payload && typeof payload === "object" && payload.ok === true) {
21
+ return payload.data;
22
+ }
23
+ return payload;
24
+ }
25
+ export async function getJson(path, signal) {
26
+ const { apiBaseUrl } = getConfig();
27
+ const url = apiBaseUrl + path;
28
+ const res = await fetch(url, { method: "GET", signal });
29
+ if (!res.ok) {
30
+ let body = "";
31
+ try {
32
+ body = await res.text();
33
+ }
34
+ catch { /* ignore */ }
35
+ throw new HttpError(`GET ${path} failed`, res.status, body);
36
+ }
37
+ const payload = (await res.json());
38
+ return unwrap(payload);
39
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /* ===========================================================
3
+ 🦊 THE HUMAN PATTERN LAB — SKULK CLI
4
+ -----------------------------------------------------------
5
+ File: notesSync.ts
6
+ Role: Command Implementation
7
+ Author: Ada (The Human Pattern Lab)
8
+ Assistant: Lyric
9
+ Status: Active
10
+ Description:
11
+ Implements the `skulk notes sync` command.
12
+ Handles human-readable and machine-readable output modes
13
+ with enforced JSON purity for automation safety.
14
+ -----------------------------------------------------------
15
+ Design Notes:
16
+ - Output format is a contract
17
+ - JSON mode emits stdout-only structured data
18
+ - Errors are written to stderr
19
+ - Exit codes are deterministic
20
+ =========================================================== */
21
+ import { Command } from "commander";
22
+ import { notesSyncCommand } from "./commands/notesSync.js";
23
+ const program = new Command();
24
+ program
25
+ .name("skulk")
26
+ .description("Skulk CLI for The Human Pattern Lab")
27
+ .version("0.1.0")
28
+ .option("--json", "Output machine-readable JSON")
29
+ .configureHelp({ helpWidth: 100 });
30
+ const argv = process.argv.slice(2);
31
+ if (argv.length === 0) {
32
+ program.outputHelp();
33
+ process.exit(0);
34
+ }
35
+ program.addCommand(notesSyncCommand());
36
+ program.parse(process.argv);
package/dist/src/io.js ADDED
@@ -0,0 +1,15 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — CLI IO GATE
3
+ -----------------------------------------------------------
4
+ Purpose: Enforce "--json means JSON only on stdout".
5
+ =========================================================== */
6
+ export function writeJson(obj) {
7
+ process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
8
+ }
9
+ export function writeHuman(text) {
10
+ process.stdout.write(text.endsWith("\n") ? text : text + "\n");
11
+ }
12
+ /** Logs to stderr only. Safe to call even in --json mode (but we avoid it by policy). */
13
+ export function logErr(message) {
14
+ process.stderr.write(message.endsWith("\n") ? message : message + "\n");
15
+ }
@@ -0,0 +1,52 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { z } from 'zod';
5
+ /**
6
+ * Skulk CLI configuration schema
7
+ * Stored in ~/.humanpatternlab/skulk.json
8
+ */
9
+ const ConfigSchema = z.object({
10
+ apiBaseUrl: z.string().url().default('https://thehumanpatternlab.com/api'),
11
+ token: z.string().optional(),
12
+ });
13
+ function getConfigPath() {
14
+ return path.join(os.homedir(), '.humanpatternlab', 'skulk.json');
15
+ }
16
+ export function loadConfig() {
17
+ const p = getConfigPath();
18
+ if (!fs.existsSync(p)) {
19
+ return ConfigSchema.parse({});
20
+ }
21
+ const raw = fs.readFileSync(p, 'utf-8');
22
+ return ConfigSchema.parse(JSON.parse(raw));
23
+ }
24
+ export function saveConfig(partial) {
25
+ const p = getConfigPath();
26
+ fs.mkdirSync(path.dirname(p), { recursive: true });
27
+ const current = loadConfig();
28
+ const next = ConfigSchema.parse({ ...current, ...partial });
29
+ fs.writeFileSync(p, JSON.stringify(next, null, 2), 'utf-8');
30
+ }
31
+ export function SKULK_BASE_URL(override) {
32
+ if (override?.trim())
33
+ return override.trim();
34
+ // NEW official env var
35
+ const env = process.env.SKULK_BASE_URL?.trim();
36
+ if (env)
37
+ return env;
38
+ // optional legacy support (remove later if you want)
39
+ const legacy = process.env.HPL_API_BASE_URL?.trim();
40
+ if (legacy)
41
+ return legacy;
42
+ return loadConfig().apiBaseUrl;
43
+ }
44
+ export function SKULK_TOKEN() {
45
+ const env = process.env.SKULK_TOKEN?.trim();
46
+ if (env)
47
+ return env;
48
+ const legacy = process.env.HPL_TOKEN?.trim();
49
+ if (legacy)
50
+ return legacy;
51
+ return loadConfig().token;
52
+ }
@@ -0,0 +1,18 @@
1
+ export async function httpJson(opts, method, path, body) {
2
+ const url = `${opts.baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
3
+ const headers = {
4
+ "Content-Type": "application/json"
5
+ };
6
+ if (opts.token)
7
+ headers["Authorization"] = `Bearer ${opts.token}`;
8
+ const res = await fetch(url, {
9
+ method,
10
+ headers,
11
+ body: body ? JSON.stringify(body) : undefined
12
+ });
13
+ const text = await res.text();
14
+ if (!res.ok) {
15
+ throw new Error(`HTTP ${res.status} ${res.statusText} @ ${url}\n${text}`);
16
+ }
17
+ return text ? JSON.parse(text) : {};
18
+ }
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import { z } from "zod";
5
+ const FrontmatterSchema = z.object({
6
+ id: z.string().optional(),
7
+ type: z.string().optional(),
8
+ title: z.string(),
9
+ subtitle: z.string().optional(),
10
+ published: z.string().optional(),
11
+ tags: z.array(z.string()).optional(),
12
+ summary: z.string().optional(),
13
+ readingTime: z.number().optional(),
14
+ status: z.enum(["published", "draft", "archived"]).optional(),
15
+ dept: z.string().optional(),
16
+ department_id: z.string().optional(),
17
+ shadow_density: z.number().optional(),
18
+ safer_landing: z.boolean().optional(),
19
+ slug: z.string().optional()
20
+ });
21
+ function slugFromFilename(filePath) {
22
+ const base = path.basename(filePath);
23
+ return base.replace(/\.mdx?$/i, "");
24
+ }
25
+ export function readNote(filePath, locale) {
26
+ const raw = fs.readFileSync(filePath, "utf-8");
27
+ const parsed = matter(raw);
28
+ const fm = FrontmatterSchema.parse(parsed.data ?? {});
29
+ const slug = (fm.slug && String(fm.slug).trim()) || slugFromFilename(filePath);
30
+ const body = parsed.content.trim();
31
+ const markdown = body.length > 0 ? body : raw.trim();
32
+ // Keep full attributes, but ensure key stuff exists
33
+ const title = (fm.title && String(fm.title).trim()) || "";
34
+ if (!title) {
35
+ throw new Error(`Missing required frontmatter: title (${filePath})`);
36
+ }
37
+ if (!markdown) {
38
+ throw new Error(`Missing required markdown content (${filePath})`);
39
+ }
40
+ return {
41
+ slug,
42
+ locale,
43
+ attributes: { ...fm, slug },
44
+ markdown
45
+ };
46
+ }
47
+ export function listMarkdownFiles(dir) {
48
+ const out = [];
49
+ const stack = [dir];
50
+ while (stack.length) {
51
+ const d = stack.pop();
52
+ const entries = fs.readdirSync(d, { withFileTypes: true });
53
+ for (const e of entries) {
54
+ const p = path.join(d, e.name);
55
+ if (e.isDirectory())
56
+ stack.push(p);
57
+ else if (e.isFile() && /\.mdx?$/i.test(e.name))
58
+ out.push(p);
59
+ }
60
+ }
61
+ return out.sort();
62
+ }
@@ -0,0 +1,17 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — TABLE RENDERER (tiny)
3
+ -----------------------------------------------------------
4
+ Purpose: Deterministic fixed-width table output.
5
+ =========================================================== */
6
+ function pad(s, width) {
7
+ const str = (s ?? "").toString();
8
+ if (str.length >= width)
9
+ return str.slice(0, Math.max(0, width - 1)) + "…";
10
+ return str + " ".repeat(width - str.length);
11
+ }
12
+ export function renderTable(rows, cols) {
13
+ const header = cols.map((c) => pad(c.header, c.width)).join(" ");
14
+ const sep = cols.map((c) => "-".repeat(c.width)).join(" ");
15
+ const body = rows.map((r) => cols.map((c) => pad(c.value(r), c.width)).join(" ")).join("\n");
16
+ return [header, sep, body].filter(Boolean).join("\n");
17
+ }
@@ -0,0 +1,40 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — TEXT RENDER UTILS
3
+ -----------------------------------------------------------
4
+ Purpose: Deterministic, dependency-free formatting for terminals.
5
+ =========================================================== */
6
+ export function stripHtml(input) {
7
+ const s = (input || "");
8
+ // Convert common structure to deterministic newlines first
9
+ const withBreaks = s
10
+ .replace(/<\s*br\s*\/?>/gi, "\n")
11
+ .replace(/<\/\s*p\s*>/gi, "\n\n")
12
+ .replace(/<\s*p[^>]*>/gi, "")
13
+ .replace(/<\s*hr\s*\/?>/gi, "\n---\n")
14
+ .replace(/<\/\s*li\s*>/gi, "\n")
15
+ .replace(/<\s*li[^>]*>/gi, "- ")
16
+ .replace(/<\/\s*ul\s*>/gi, "\n")
17
+ .replace(/<\s*ul[^>]*>/gi, "");
18
+ // Strip scripts/styles then remaining tags
19
+ const stripped = withBreaks
20
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
21
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
22
+ .replace(/<[^>]+>/g, "");
23
+ // Decode a few entities + normalize whitespace
24
+ return stripped
25
+ .replace(/&nbsp;/g, " ")
26
+ .replace(/&amp;/g, "&")
27
+ .replace(/&lt;/g, "<")
28
+ .replace(/&gt;/g, ">")
29
+ .replace(/\r\n/g, "\n")
30
+ .replace(/[ \t]+\n/g, "\n")
31
+ .replace(/\n{3,}/g, "\n\n")
32
+ .trim();
33
+ }
34
+ export function safeLine(s) {
35
+ return (s ?? "").replace(/\s+/g, " ").trim();
36
+ }
37
+ export function formatTags(tags) {
38
+ const t = (tags ?? []).filter(Boolean);
39
+ return t.length ? t.join(", ") : "-";
40
+ }
@@ -0,0 +1,10 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ payload;
4
+ constructor(message, status, payload) {
5
+ super(message);
6
+ this.name = "ApiError";
7
+ this.status = status;
8
+ this.payload = payload;
9
+ }
10
+ }
@@ -0,0 +1,101 @@
1
+ // src/sdk/LabClient.ts
2
+ export class LabClient {
3
+ baseUrl;
4
+ token;
5
+ constructor(baseUrl, token) {
6
+ // baseUrl should be like: https://api.thehumanpatternlab.com
7
+ // (no trailing slash, no /api)
8
+ this.baseUrl = baseUrl.replace(/\/$/, "");
9
+ this.token = token;
10
+ }
11
+ setToken(token) {
12
+ this.token = token;
13
+ }
14
+ buildHeaders(extra, auth = "none") {
15
+ const h = { ...(extra ?? {}) };
16
+ // Only set JSON content-type when we actually send a JSON body
17
+ // (some servers get weird on GETs with Content-Type set)
18
+ if (!h["Content-Type"])
19
+ h["Content-Type"] = "application/json";
20
+ if (auth === "bearer" && this.token) {
21
+ h["Authorization"] = `Bearer ${this.token}`;
22
+ }
23
+ return h;
24
+ }
25
+ async request(path, opts = {}) {
26
+ const url = `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
27
+ const auth = opts.auth ?? "none";
28
+ const headers = this.buildHeaders(opts.headers, auth);
29
+ const res = await fetch(url, {
30
+ ...opts,
31
+ headers,
32
+ credentials: auth === "cookie" ? "include" : opts.credentials,
33
+ });
34
+ // Best-effort parse
35
+ const payload = await res.json().catch(() => null);
36
+ if (!res.ok) {
37
+ const msg = typeof payload === "object" &&
38
+ payload !== null &&
39
+ ("error" in payload || "message" in payload) &&
40
+ typeof payload.error === "string"
41
+ ? payload.error
42
+ : typeof payload?.message === "string"
43
+ ? payload.message
44
+ : `Request failed (${res.status})`;
45
+ throw new Error(msg);
46
+ }
47
+ return payload;
48
+ }
49
+ //
50
+ // ────────────────────────────────────────────────
51
+ // PUBLIC ROUTES
52
+ // ────────────────────────────────────────────────
53
+ //
54
+ // ✅ was GET `${baseUrl}/`
55
+ async getAllNotes() {
56
+ return this.request("/lab-notes", { method: "GET", auth: "none" });
57
+ }
58
+ // ✅ was GET `${baseUrl}/notes/${slug}`
59
+ async getNoteBySlug(slug) {
60
+ return this.request(`/lab-notes/${encodeURIComponent(slug)}`, {
61
+ method: "GET",
62
+ auth: "none",
63
+ });
64
+ }
65
+ //
66
+ // ────────────────────────────────────────────────
67
+ // ADMIN ROUTES
68
+ // ────────────────────────────────────────────────
69
+ //
70
+ // ✅ was `/api/admin/notes`
71
+ async getAdminNotes() {
72
+ return this.request("/admin/notes", {
73
+ method: "GET",
74
+ auth: "cookie",
75
+ });
76
+ }
77
+ // ✅ was `/api/admin/notes`
78
+ async createOrUpdateNote(input) {
79
+ return this.request("/admin/notes", {
80
+ method: "POST",
81
+ auth: "cookie",
82
+ body: JSON.stringify(input),
83
+ });
84
+ }
85
+ // ✅ was `/api/admin/notes/${id}`
86
+ async deleteNote(id) {
87
+ return this.request(`/admin/notes/${encodeURIComponent(id)}`, {
88
+ method: "DELETE",
89
+ auth: "cookie",
90
+ });
91
+ }
92
+ //
93
+ // ────────────────────────────────────────────────
94
+ // HEALTH
95
+ // ────────────────────────────────────────────────
96
+ //
97
+ // ✅ was `/api/health`
98
+ async health() {
99
+ return this.request("/health", { method: "GET", auth: "none" });
100
+ }
101
+ }
@@ -0,0 +1,25 @@
1
+ export function buildSyncSummary(results, dryRunMode) {
2
+ let synced = 0;
3
+ let dryRun = 0;
4
+ let failed = 0;
5
+ for (const r of results) {
6
+ if (r.status === "failed") {
7
+ failed++;
8
+ continue;
9
+ }
10
+ if (dryRunMode)
11
+ dryRun++;
12
+ else
13
+ synced++;
14
+ }
15
+ const total = results.length;
16
+ // Invariant: dry-run must never report synced writes
17
+ if (dryRunMode && synced > 0) {
18
+ throw new Error("Invariant violation: dry-run mode cannot produce synced > 0");
19
+ }
20
+ // Invariant: accounting must balance
21
+ if (synced + dryRun + failed !== total) {
22
+ throw new Error("SyncSummary invariant failed: counts do not add up to total");
23
+ }
24
+ return { synced, dryRun, failed, total };
25
+ }
@@ -0,0 +1,2 @@
1
+ // src/sync/types.ts
2
+ export {};
@@ -0,0 +1,27 @@
1
+ /* ===========================================================
2
+ 🌌 HUMAN PATTERN LAB — TYPES: Lab Notes (CLI)
3
+ -----------------------------------------------------------
4
+ Purpose: API contract types for Lab Notes used by the CLI.
5
+ Notes:
6
+ - Keep permissive: API may add fields (additive).
7
+ =========================================================== */
8
+ import { z } from "zod";
9
+ export const LabNoteSchema = z.object({
10
+ id: z.string(),
11
+ slug: z.string(),
12
+ title: z.string(),
13
+ summary: z.string().optional().default(""),
14
+ contentHtml: z.string().optional().default(""),
15
+ published: z.string().optional().nullable(),
16
+ status: z.string().optional(),
17
+ type: z.string().optional(),
18
+ locale: z.string().optional(),
19
+ department_id: z.string().optional(),
20
+ shadow_density: z.number().optional(),
21
+ safer_landing: z.boolean().optional(),
22
+ tags: z.array(z.string()).optional().default([]),
23
+ readingTime: z.number().optional(),
24
+ created_at: z.string().optional(),
25
+ updated_at: z.string().optional(),
26
+ }).passthrough();
27
+ export const LabNoteListSchema = z.array(LabNoteSchema);
@@ -0,0 +1,10 @@
1
+ import fs from 'fs';
2
+ import matter from 'gray-matter';
3
+ export function loadMarkdown(filePath) {
4
+ const raw = fs.readFileSync(filePath, 'utf8');
5
+ const parsed = matter(raw);
6
+ return {
7
+ content: parsed.content.trim(),
8
+ metadata: parsed.data
9
+ };
10
+ }