@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.
- package/README.md +42 -0
- package/dist/__tests__/config.test.js +22 -0
- package/dist/__tests__/outputContract.test.js +58 -0
- package/dist/bin/hpl.js +158 -0
- package/dist/cli/output.js +21 -0
- package/dist/cli/outputContract.js +32 -0
- package/dist/commands/notesSync.js +191 -0
- package/dist/commands/publish.js +36 -0
- package/dist/index.js +36 -0
- package/dist/lab.js +10 -0
- package/dist/lib/config.js +52 -0
- package/dist/lib/http.js +18 -0
- package/dist/lib/notes.js +62 -0
- package/dist/lib/output.js +21 -0
- package/dist/sdk/LabClient.js +94 -0
- package/dist/src/__tests__/config.test.js +22 -0
- package/dist/src/__tests__/outputContract.test.js +58 -0
- package/dist/src/cli/output.js +21 -0
- package/dist/src/cli/outputContract.js +32 -0
- package/dist/src/commands/capabilities.js +10 -0
- package/dist/src/commands/health.js +48 -0
- package/dist/src/commands/notes/get.js +48 -0
- package/dist/src/commands/notes/list.js +43 -0
- package/dist/src/commands/notesSync.js +191 -0
- package/dist/src/commands/version.js +12 -0
- package/dist/src/config.js +10 -0
- package/dist/src/contract/capabilities.js +15 -0
- package/dist/src/contract/envelope.js +16 -0
- package/dist/src/contract/exitCodes.js +21 -0
- package/dist/src/contract/intents.js +49 -0
- package/dist/src/contract/schema.js +59 -0
- package/dist/src/http/client.js +39 -0
- package/dist/src/index.js +36 -0
- package/dist/src/io.js +15 -0
- package/dist/src/lib/config.js +52 -0
- package/dist/src/lib/http.js +18 -0
- package/dist/src/lib/notes.js +62 -0
- package/dist/src/render/table.js +17 -0
- package/dist/src/render/text.js +40 -0
- package/dist/src/sdk/ApiError.js +10 -0
- package/dist/src/sdk/LabClient.js +101 -0
- package/dist/src/sync/summary.js +25 -0
- package/dist/src/sync/types.js +2 -0
- package/dist/src/types/labNotes.js +27 -0
- package/dist/src/utils/loadMarkdown.js +10 -0
- package/dist/sync/summary.js +25 -0
- package/dist/sync/types.js +2 -0
- package/dist/utils/loadMarkdown.js +10 -0
- package/package.json +70 -0
|
@@ -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,21 @@
|
|
|
1
|
+
// Output is a contract. If it breaks, it should break loudly.
|
|
2
|
+
function getRootCommand(cmd) {
|
|
3
|
+
let cur = cmd;
|
|
4
|
+
while (cur.parent)
|
|
5
|
+
cur = cur.parent;
|
|
6
|
+
return cur;
|
|
7
|
+
}
|
|
8
|
+
export function getOutputMode(cmd) {
|
|
9
|
+
const root = getRootCommand(cmd);
|
|
10
|
+
const opts = root.opts?.() ?? {};
|
|
11
|
+
return opts.json ? "json" : "human";
|
|
12
|
+
}
|
|
13
|
+
export function printJson(data) {
|
|
14
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
15
|
+
}
|
|
16
|
+
export function printError(message) {
|
|
17
|
+
process.stderr.write(message + "\n");
|
|
18
|
+
}
|
|
19
|
+
export function printJsonError(message, extra) {
|
|
20
|
+
process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) + "\n");
|
|
21
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// src/sdk/LabClient.ts
|
|
2
|
+
export class LabClient {
|
|
3
|
+
baseUrl;
|
|
4
|
+
token;
|
|
5
|
+
constructor(baseUrl, token) {
|
|
6
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
7
|
+
this.token = token;
|
|
8
|
+
}
|
|
9
|
+
setToken(token) {
|
|
10
|
+
this.token = token;
|
|
11
|
+
}
|
|
12
|
+
headers() {
|
|
13
|
+
const h = {
|
|
14
|
+
'Content-Type': 'application/json'
|
|
15
|
+
};
|
|
16
|
+
if (this.token) {
|
|
17
|
+
h['Authorization'] = `Bearer ${this.token}`;
|
|
18
|
+
}
|
|
19
|
+
return h;
|
|
20
|
+
}
|
|
21
|
+
//
|
|
22
|
+
// ────────────────────────────────────────────────
|
|
23
|
+
// PUBLIC ROUTES
|
|
24
|
+
// ────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
async getAllNotes() {
|
|
27
|
+
const res = await fetch(`${this.baseUrl}/`, {
|
|
28
|
+
method: 'GET',
|
|
29
|
+
headers: this.headers()
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
throw new Error('Failed to fetch notes');
|
|
33
|
+
return res.json();
|
|
34
|
+
}
|
|
35
|
+
async getNoteBySlug(slug) {
|
|
36
|
+
const res = await fetch(`${this.baseUrl}/notes/${slug}`, {
|
|
37
|
+
method: 'GET',
|
|
38
|
+
headers: this.headers()
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error('Note not found');
|
|
42
|
+
return res.json();
|
|
43
|
+
}
|
|
44
|
+
//
|
|
45
|
+
// ────────────────────────────────────────────────
|
|
46
|
+
// ADMIN ROUTES
|
|
47
|
+
// ────────────────────────────────────────────────
|
|
48
|
+
//
|
|
49
|
+
async getAdminNotes() {
|
|
50
|
+
const res = await fetch(`${this.baseUrl}/api/admin/notes`, {
|
|
51
|
+
method: 'GET',
|
|
52
|
+
credentials: 'include',
|
|
53
|
+
headers: this.headers()
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw new Error('Failed to fetch admin notes');
|
|
57
|
+
return res.json();
|
|
58
|
+
}
|
|
59
|
+
async createOrUpdateNote(input) {
|
|
60
|
+
const res = await fetch(`${this.baseUrl}/api/admin/notes`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
credentials: 'include',
|
|
63
|
+
headers: this.headers(),
|
|
64
|
+
body: JSON.stringify(input)
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const err = await res.json().catch(() => ({}));
|
|
68
|
+
throw new Error(err.error || 'Failed to save note');
|
|
69
|
+
}
|
|
70
|
+
return res.json();
|
|
71
|
+
}
|
|
72
|
+
async deleteNote(id) {
|
|
73
|
+
const res = await fetch(`${this.baseUrl}/api/admin/notes/${id}`, {
|
|
74
|
+
method: 'DELETE',
|
|
75
|
+
credentials: 'include',
|
|
76
|
+
headers: this.headers()
|
|
77
|
+
});
|
|
78
|
+
if (!res.ok)
|
|
79
|
+
throw new Error('Failed to delete note');
|
|
80
|
+
return res.json();
|
|
81
|
+
}
|
|
82
|
+
//
|
|
83
|
+
// ────────────────────────────────────────────────
|
|
84
|
+
// HEALTH
|
|
85
|
+
// ────────────────────────────────────────────────
|
|
86
|
+
//
|
|
87
|
+
async health() {
|
|
88
|
+
const res = await fetch(`${this.baseUrl}/api/health`, {
|
|
89
|
+
method: 'GET',
|
|
90
|
+
headers: this.headers()
|
|
91
|
+
});
|
|
92
|
+
return res.json();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from 'vitest';
|
|
2
|
+
import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js';
|
|
3
|
+
describe('env config', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
delete process.env.SKULK_BASE_URL;
|
|
6
|
+
delete process.env.SKULK_TOKEN;
|
|
7
|
+
delete process.env.HPL_API_BASE_URL;
|
|
8
|
+
delete process.env.HPL_TOKEN;
|
|
9
|
+
});
|
|
10
|
+
it('uses SKULK_TOKEN when set', () => {
|
|
11
|
+
process.env.SKULK_TOKEN = 'abc123';
|
|
12
|
+
expect(SKULK_TOKEN()).toBe('abc123');
|
|
13
|
+
});
|
|
14
|
+
it('uses SKULK_BASE_URL when set', () => {
|
|
15
|
+
process.env.SKULK_BASE_URL = 'https://example.com/api';
|
|
16
|
+
expect(SKULK_BASE_URL()).toBe('https://example.com/api');
|
|
17
|
+
});
|
|
18
|
+
it('override beats SKULK_BASE_URL', () => {
|
|
19
|
+
process.env.SKULK_BASE_URL = 'https://example.com/api';
|
|
20
|
+
expect(SKULK_BASE_URL('https://override.com/api')).toBe('https://override.com/api');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildSyncReport } from '../cli/outputContract.js';
|
|
3
|
+
describe('buildSyncReport (JSON contract)', () => {
|
|
4
|
+
it('counts synced in live mode (dryRun=false)', () => {
|
|
5
|
+
const report = buildSyncReport({
|
|
6
|
+
dryRun: false,
|
|
7
|
+
locale: 'en',
|
|
8
|
+
baseUrl: 'https://example.com/api',
|
|
9
|
+
results: [
|
|
10
|
+
{ file: 'a.md', slug: 'a', status: 'ok', action: 'updated' },
|
|
11
|
+
{ file: 'b.md', slug: 'b', status: 'ok', action: 'created' },
|
|
12
|
+
],
|
|
13
|
+
});
|
|
14
|
+
expect(report.ok).toBe(true);
|
|
15
|
+
expect(report.summary).toEqual({
|
|
16
|
+
synced: 2,
|
|
17
|
+
dryRun: 0,
|
|
18
|
+
failed: 0,
|
|
19
|
+
total: 2,
|
|
20
|
+
});
|
|
21
|
+
expect(report.results.every((r) => r.written === true)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('counts dryRun in dry-run mode (dryRun=true)', () => {
|
|
24
|
+
const report = buildSyncReport({
|
|
25
|
+
dryRun: true,
|
|
26
|
+
results: [
|
|
27
|
+
{ file: 'a.md', slug: 'a', status: 'dry-run' },
|
|
28
|
+
{ file: 'b.md', slug: 'b', status: 'dry-run' },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
expect(report.ok).toBe(true);
|
|
32
|
+
expect(report.dryRun).toBe(true);
|
|
33
|
+
expect(report.summary).toEqual({
|
|
34
|
+
synced: 0,
|
|
35
|
+
dryRun: 2,
|
|
36
|
+
failed: 0,
|
|
37
|
+
total: 2,
|
|
38
|
+
});
|
|
39
|
+
expect(report.results.every((r) => r.written === false)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('normalizes legacy fail -> failed and keeps totals balanced', () => {
|
|
42
|
+
const report = buildSyncReport({
|
|
43
|
+
dryRun: false,
|
|
44
|
+
results: [
|
|
45
|
+
{ file: 'a.md', slug: 'a', status: 'ok' },
|
|
46
|
+
{ file: 'b.md', status: 'fail', error: 'boom' },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
expect(report.ok).toBe(false);
|
|
50
|
+
expect(report.summary).toEqual({
|
|
51
|
+
synced: 1,
|
|
52
|
+
dryRun: 0,
|
|
53
|
+
failed: 1,
|
|
54
|
+
total: 2,
|
|
55
|
+
});
|
|
56
|
+
expect(report.results[1].status).toBe('failed');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Output is a contract. If it breaks, it should break loudly.
|
|
2
|
+
function getRootCommand(cmd) {
|
|
3
|
+
let cur = cmd;
|
|
4
|
+
while (cur.parent)
|
|
5
|
+
cur = cur.parent;
|
|
6
|
+
return cur;
|
|
7
|
+
}
|
|
8
|
+
export function getOutputMode(cmd) {
|
|
9
|
+
const root = getRootCommand(cmd);
|
|
10
|
+
const opts = root.opts?.() ?? {};
|
|
11
|
+
return opts.json ? "json" : "human";
|
|
12
|
+
}
|
|
13
|
+
export function printJson(data) {
|
|
14
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
15
|
+
}
|
|
16
|
+
export function printError(message) {
|
|
17
|
+
process.stderr.write(message + "\n");
|
|
18
|
+
}
|
|
19
|
+
export function printJsonError(message, extra) {
|
|
20
|
+
process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) + "\n");
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/cli/outputContract.ts
|
|
2
|
+
import { buildSyncSummary } from "../sync/summary.js";
|
|
3
|
+
function normalizeResult(r, dryRunFlag) {
|
|
4
|
+
const status = r.status === "fail" ? "failed" : "ok"; // "dry-run" is not a failure
|
|
5
|
+
// If any result claims "dry-run", treat it as dry-run even if flag is off (safety)
|
|
6
|
+
const effectiveDryRun = dryRunFlag || r.status === "dry-run";
|
|
7
|
+
return {
|
|
8
|
+
file: r.file,
|
|
9
|
+
slug: r.slug,
|
|
10
|
+
status,
|
|
11
|
+
action: r.action,
|
|
12
|
+
error: r.error,
|
|
13
|
+
// written only when not dry-run and ok
|
|
14
|
+
written: !effectiveDryRun && status === "ok",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function buildSyncReport(args) {
|
|
18
|
+
const { results, dryRun, locale, baseUrl } = args;
|
|
19
|
+
const normalized = results.map((r) => normalizeResult(r, dryRun));
|
|
20
|
+
// IMPORTANT: pass the effective dryRun mode you used for written calc
|
|
21
|
+
// If you want to be ultra strict, you could compute effectiveDryRun = dryRun || any(r.status==="dry-run")
|
|
22
|
+
const effectiveDryRun = dryRun || results.some((r) => r.status === "dry-run");
|
|
23
|
+
const summary = buildSyncSummary(normalized, effectiveDryRun);
|
|
24
|
+
return {
|
|
25
|
+
ok: summary.failed === 0,
|
|
26
|
+
summary,
|
|
27
|
+
results: normalized,
|
|
28
|
+
dryRun: effectiveDryRun,
|
|
29
|
+
locale,
|
|
30
|
+
baseUrl,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🌌 HUMAN PATTERN LAB — COMMAND: capabilities
|
|
3
|
+
=========================================================== */
|
|
4
|
+
import { getAlphaIntent } from "../contract/intents";
|
|
5
|
+
import { ok } from "../contract/envelope";
|
|
6
|
+
import { getCapabilitiesAlpha } from "../contract/capabilities";
|
|
7
|
+
export function runCapabilities(commandName = "capabilities") {
|
|
8
|
+
const intent = getAlphaIntent("show_capabilities");
|
|
9
|
+
return ok(commandName, intent, getCapabilitiesAlpha());
|
|
10
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🌌 HUMAN PATTERN LAB — COMMAND: health
|
|
3
|
+
=========================================================== */
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { getAlphaIntent } from "../contract/intents";
|
|
6
|
+
import { ok, err } from "../contract/envelope";
|
|
7
|
+
import { EXIT } from "../contract/exitCodes";
|
|
8
|
+
import { getJson, HttpError } from "../http/client";
|
|
9
|
+
const HealthSchema = z.object({
|
|
10
|
+
status: z.string(),
|
|
11
|
+
dbPath: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
export async function runHealth(commandName = "health") {
|
|
14
|
+
const intent = getAlphaIntent("check_health");
|
|
15
|
+
try {
|
|
16
|
+
const payload = await getJson("/health");
|
|
17
|
+
const parsed = HealthSchema.safeParse(payload);
|
|
18
|
+
if (!parsed.success) {
|
|
19
|
+
return {
|
|
20
|
+
envelope: err(commandName, intent, {
|
|
21
|
+
code: "E_CONTRACT",
|
|
22
|
+
message: "Health response did not match expected schema",
|
|
23
|
+
details: parsed.error.flatten(),
|
|
24
|
+
}),
|
|
25
|
+
exitCode: EXIT.CONTRACT,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { envelope: ok(commandName, intent, parsed.data), exitCode: EXIT.OK };
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
if (e instanceof HttpError) {
|
|
32
|
+
const code = e.status && e.status >= 500 ? "E_SERVER" : "E_HTTP";
|
|
33
|
+
return {
|
|
34
|
+
envelope: err(commandName, intent, {
|
|
35
|
+
code,
|
|
36
|
+
message: `API request failed (${e.status ?? "unknown"})`,
|
|
37
|
+
details: e.body ? e.body.slice(0, 500) : undefined,
|
|
38
|
+
}),
|
|
39
|
+
exitCode: e.status && e.status >= 500 ? EXIT.SERVER : EXIT.NETWORK,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
43
|
+
return {
|
|
44
|
+
envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }),
|
|
45
|
+
exitCode: EXIT.UNKNOWN,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🌌 HUMAN PATTERN LAB — COMMAND: notes get <slug>
|
|
3
|
+
=========================================================== */
|
|
4
|
+
import { getAlphaIntent } from "../../contract/intents";
|
|
5
|
+
import { ok, err } from "../../contract/envelope";
|
|
6
|
+
import { EXIT } from "../../contract/exitCodes";
|
|
7
|
+
import { getJson, HttpError } from "../../http/client";
|
|
8
|
+
import { LabNoteSchema } from "../../types/labNotes";
|
|
9
|
+
export async function runNotesGet(slug, commandName = "notes get") {
|
|
10
|
+
const intent = getAlphaIntent("render_lab_note");
|
|
11
|
+
try {
|
|
12
|
+
const payload = await getJson(`/lab-notes/${encodeURIComponent(slug)}`);
|
|
13
|
+
const parsed = LabNoteSchema.safeParse(payload);
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
return {
|
|
16
|
+
envelope: err(commandName, intent, {
|
|
17
|
+
code: "E_CONTRACT",
|
|
18
|
+
message: "Lab Note did not match expected schema",
|
|
19
|
+
details: parsed.error.flatten(),
|
|
20
|
+
}),
|
|
21
|
+
exitCode: EXIT.CONTRACT,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const note = parsed.data;
|
|
25
|
+
return { envelope: ok(commandName, intent, note), exitCode: EXIT.OK };
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
if (e instanceof HttpError) {
|
|
29
|
+
if (e.status == 404) {
|
|
30
|
+
return {
|
|
31
|
+
envelope: err(commandName, intent, { code: "E_NOT_FOUND", message: `No lab note found for slug: ${slug}` }),
|
|
32
|
+
exitCode: EXIT.NOT_FOUND,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const code = e.status && e.status >= 500 ? "E_SERVER" : "E_HTTP";
|
|
36
|
+
return {
|
|
37
|
+
envelope: err(commandName, intent, {
|
|
38
|
+
code,
|
|
39
|
+
message: `API request failed (${e.status ?? "unknown"})`,
|
|
40
|
+
details: e.body ? e.body.slice(0, 500) : undefined,
|
|
41
|
+
}),
|
|
42
|
+
exitCode: e.status && e.status >= 500 ? EXIT.SERVER : EXIT.NETWORK,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
46
|
+
return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🌌 HUMAN PATTERN LAB — COMMAND: notes list
|
|
3
|
+
=========================================================== */
|
|
4
|
+
import { getAlphaIntent } from "../../contract/intents";
|
|
5
|
+
import { ok, err } from "../../contract/envelope";
|
|
6
|
+
import { EXIT } from "../../contract/exitCodes";
|
|
7
|
+
import { getJson, HttpError } from "../../http/client";
|
|
8
|
+
import { LabNoteListSchema } from "../../types/labNotes";
|
|
9
|
+
export async function runNotesList(commandName = "notes list") {
|
|
10
|
+
const intent = getAlphaIntent("render_lab_note");
|
|
11
|
+
try {
|
|
12
|
+
const payload = await getJson("/lab-notes");
|
|
13
|
+
const parsed = LabNoteListSchema.safeParse(payload);
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
return {
|
|
16
|
+
envelope: err(commandName, intent, {
|
|
17
|
+
code: "E_CONTRACT",
|
|
18
|
+
message: "Lab Notes list did not match expected schema",
|
|
19
|
+
details: parsed.error.flatten(),
|
|
20
|
+
}),
|
|
21
|
+
exitCode: EXIT.CONTRACT,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Deterministic: preserve API order, but ensure stable array type.
|
|
25
|
+
const notes = parsed.data;
|
|
26
|
+
return { envelope: ok(commandName, intent, { count: notes.length, notes }), exitCode: EXIT.OK };
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e instanceof HttpError) {
|
|
30
|
+
const code = e.status && e.status >= 500 ? "E_SERVER" : "E_HTTP";
|
|
31
|
+
return {
|
|
32
|
+
envelope: err(commandName, intent, {
|
|
33
|
+
code,
|
|
34
|
+
message: `API request failed (${e.status ?? "unknown"})`,
|
|
35
|
+
details: e.body ? e.body.slice(0, 500) : undefined,
|
|
36
|
+
}),
|
|
37
|
+
exitCode: e.status && e.status >= 500 ? EXIT.SERVER : EXIT.NETWORK,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
41
|
+
return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🦊 THE HUMAN PATTERN LAB — SKULK CLI
|
|
3
|
+
-----------------------------------------------------------
|
|
4
|
+
Author: Ada (Founder, The Human Pattern Lab)
|
|
5
|
+
Assistant: Lyric (AI Lab Companion)
|
|
6
|
+
File: notesSync.ts
|
|
7
|
+
Module: Notes Command Suite
|
|
8
|
+
Lab Unit: SCMS — Systems & Code Management Suite
|
|
9
|
+
Status: Active
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
Purpose:
|
|
12
|
+
Implements `skulk notes sync` — syncing local markdown Lab Notes
|
|
13
|
+
to the Lab API with predictable behavior in both human and
|
|
14
|
+
automation contexts.
|
|
15
|
+
-----------------------------------------------------------
|
|
16
|
+
Key Behaviors:
|
|
17
|
+
- Human mode: readable progress + summaries
|
|
18
|
+
- JSON mode (--json): stdout emits ONLY valid JSON (contract)
|
|
19
|
+
- Errors: stderr only
|
|
20
|
+
- Exit codes: deterministic (non-zero only on failure)
|
|
21
|
+
-----------------------------------------------------------
|
|
22
|
+
Notes:
|
|
23
|
+
JSON output is a contract. If it breaks, it should break loudly.
|
|
24
|
+
=========================================================== */
|
|
25
|
+
/**
|
|
26
|
+
* @file notesSync.ts
|
|
27
|
+
* @author Ada
|
|
28
|
+
* @assistant Lyric
|
|
29
|
+
* @lab-unit SCMS — Systems & Code Management Suite
|
|
30
|
+
* @since 2025-12-28
|
|
31
|
+
* @description Syncs markdown Lab Notes to the API via `skulk notes sync`.
|
|
32
|
+
* Supports human + JSON output modes; JSON mode is stdout-pure.
|
|
33
|
+
*/
|
|
34
|
+
import { Command } from 'commander';
|
|
35
|
+
import { SKULK_BASE_URL, SKULK_TOKEN } from '../lib/config.js';
|
|
36
|
+
import { httpJson } from '../lib/http.js';
|
|
37
|
+
import { listMarkdownFiles, readNote } from '../lib/notes.js';
|
|
38
|
+
import { getOutputMode, printJson } from '../cli/output.js';
|
|
39
|
+
import { buildSyncReport } from '../cli/outputContract.js';
|
|
40
|
+
import fs from 'node:fs';
|
|
41
|
+
async function upsertNote(baseUrl, token, note, locale) {
|
|
42
|
+
const payload = {
|
|
43
|
+
slug: note.slug,
|
|
44
|
+
title: note.attributes.title,
|
|
45
|
+
markdown: note.markdown,
|
|
46
|
+
locale,
|
|
47
|
+
};
|
|
48
|
+
if (!payload.slug || !payload.title || !payload.markdown) {
|
|
49
|
+
throw new Error(`Invalid note payload: slug/title/markdown missing for ${payload.slug ?? 'unknown'}`);
|
|
50
|
+
}
|
|
51
|
+
return httpJson({ baseUrl, token }, 'POST', '/lab-notes/upsert', payload);
|
|
52
|
+
}
|
|
53
|
+
export function notesSyncCommand() {
|
|
54
|
+
const notes = new Command('notes').description('Lab Notes commands');
|
|
55
|
+
notes
|
|
56
|
+
.command('sync')
|
|
57
|
+
.description('Sync local markdown notes to the API')
|
|
58
|
+
.option('--dir <path>', 'Directory containing markdown notes', './src/labnotes/en')
|
|
59
|
+
//.option("--dir <path>", "Directory containing markdown notes", "./labnotes/en")
|
|
60
|
+
.option('--locale <code>', 'Locale code', 'en')
|
|
61
|
+
.option('--base-url <url>', 'Override API base URL (ex: https://thehumanpatternlab.com/api)')
|
|
62
|
+
.option('--dry-run', 'Print what would be sent, but do not call the API', false)
|
|
63
|
+
.option('--only <slug>', 'Sync only a single note by slug')
|
|
64
|
+
.option('--limit <n>', 'Sync only the first N notes', (v) => parseInt(v, 10))
|
|
65
|
+
.action(async (opts, cmd) => {
|
|
66
|
+
const mode = getOutputMode(cmd); // "json" | "human"
|
|
67
|
+
const jsonError = (message, extra) => {
|
|
68
|
+
if (mode === 'json') {
|
|
69
|
+
process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) +
|
|
70
|
+
'\n');
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.error(message);
|
|
74
|
+
if (extra)
|
|
75
|
+
console.error(extra);
|
|
76
|
+
}
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
};
|
|
79
|
+
if (!fs.existsSync(opts.dir)) {
|
|
80
|
+
jsonError(`Notes directory not found: ${opts.dir}`, {
|
|
81
|
+
hint: `Try: skulk notes sync --dir "..\\\\the-human-pattern-lab\\\\src\\\\labnotes\\\\en"`,
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const baseUrl = SKULK_BASE_URL(opts.baseUrl);
|
|
86
|
+
const token = SKULK_TOKEN();
|
|
87
|
+
const files = listMarkdownFiles(opts.dir);
|
|
88
|
+
let selectedFiles = files;
|
|
89
|
+
if (opts.only) {
|
|
90
|
+
selectedFiles = files.filter((f) => f.toLowerCase().includes(opts.only.toLowerCase()));
|
|
91
|
+
}
|
|
92
|
+
if (opts.limit && Number.isFinite(opts.limit)) {
|
|
93
|
+
selectedFiles = selectedFiles.slice(0, opts.limit);
|
|
94
|
+
}
|
|
95
|
+
if (selectedFiles.length === 0) {
|
|
96
|
+
if (mode === 'json') {
|
|
97
|
+
printJson({
|
|
98
|
+
ok: true,
|
|
99
|
+
action: 'noop',
|
|
100
|
+
message: 'No matching notes found.',
|
|
101
|
+
matched: 0,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.log('No matching notes found.');
|
|
106
|
+
}
|
|
107
|
+
process.exitCode = 0;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Human-mode header chatter
|
|
111
|
+
if (mode === 'human') {
|
|
112
|
+
console.log(`Skulk syncing ${selectedFiles.length} note(s) from ${opts.dir}`);
|
|
113
|
+
console.log(`API: ${baseUrl}`);
|
|
114
|
+
console.log(`Locale: ${opts.locale}`);
|
|
115
|
+
console.log(opts.dryRun ? 'Mode: DRY RUN (no writes)' : 'Mode: LIVE (writing)');
|
|
116
|
+
}
|
|
117
|
+
let ok = 0;
|
|
118
|
+
let fail = 0;
|
|
119
|
+
const results = [];
|
|
120
|
+
for (const file of selectedFiles) {
|
|
121
|
+
try {
|
|
122
|
+
const note = readNote(file, opts.locale);
|
|
123
|
+
if (opts.dryRun) {
|
|
124
|
+
ok++;
|
|
125
|
+
results.push({
|
|
126
|
+
file,
|
|
127
|
+
slug: note.slug,
|
|
128
|
+
status: 'dry-run',
|
|
129
|
+
});
|
|
130
|
+
if (mode === 'human') {
|
|
131
|
+
console.log(`\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(', ')}`);
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const res = await upsertNote(baseUrl, token, note, opts.locale);
|
|
136
|
+
ok++;
|
|
137
|
+
results.push({
|
|
138
|
+
file,
|
|
139
|
+
slug: note.slug,
|
|
140
|
+
status: 'ok',
|
|
141
|
+
action: res.action,
|
|
142
|
+
});
|
|
143
|
+
if (mode === 'human') {
|
|
144
|
+
console.log(`✅ ${note.slug} (${res.action ?? 'ok'})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
fail++;
|
|
149
|
+
const msg = String(e);
|
|
150
|
+
results.push({
|
|
151
|
+
file,
|
|
152
|
+
status: 'fail',
|
|
153
|
+
error: msg,
|
|
154
|
+
});
|
|
155
|
+
if (mode === 'human') {
|
|
156
|
+
console.error(`❌ ${file}`);
|
|
157
|
+
console.error(msg);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (mode === 'json') {
|
|
162
|
+
const report = buildSyncReport({
|
|
163
|
+
results,
|
|
164
|
+
dryRun: Boolean(opts.dryRun),
|
|
165
|
+
locale: opts.locale,
|
|
166
|
+
baseUrl,
|
|
167
|
+
});
|
|
168
|
+
printJson(report);
|
|
169
|
+
if (!report.ok)
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const report = buildSyncReport({
|
|
174
|
+
results,
|
|
175
|
+
dryRun: Boolean(opts.dryRun),
|
|
176
|
+
locale: opts.locale,
|
|
177
|
+
baseUrl,
|
|
178
|
+
});
|
|
179
|
+
const { synced, dryRun, failed } = report.summary;
|
|
180
|
+
if (report.dryRun) {
|
|
181
|
+
console.log(`\nDone. ${dryRun} note(s) would be synced (dry-run). Failures: ${failed}`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.log(`\nDone. ${synced} note(s) synced successfully. Failures: ${failed}`);
|
|
185
|
+
}
|
|
186
|
+
if (!report.ok)
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return notes;
|
|
191
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🌌 HUMAN PATTERN LAB — COMMAND: version
|
|
3
|
+
=========================================================== */
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { getAlphaIntent } from "../contract/intents";
|
|
6
|
+
import { ok } from "../contract/envelope";
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require("../../package.json");
|
|
9
|
+
export function runVersion(commandName = "version") {
|
|
10
|
+
const intent = getAlphaIntent("show_version");
|
|
11
|
+
return ok(commandName, intent, { name: pkg.name, version: pkg.version });
|
|
12
|
+
}
|