@thehumanpatternlab/hpl 0.0.1-alpha.5 → 1.0.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/README.md +182 -42
- package/dist/bin/hpl.js +20 -145
- package/dist/src/commands/capabilities.js +24 -0
- package/dist/src/commands/health.js +26 -0
- package/dist/src/commands/notes/create.js +183 -0
- package/dist/src/commands/notes/get.js +52 -12
- package/dist/src/commands/notes/list.js +68 -12
- package/dist/src/commands/notes/notes.js +36 -0
- package/dist/src/commands/notes/notesSync.js +221 -0
- package/dist/src/commands/notes/update.js +217 -0
- package/dist/src/commands/notesSync.js +1 -1
- package/dist/src/commands/version.js +17 -0
- package/dist/src/contract/exitCodes.js +2 -0
- package/dist/src/contract/intents.js +14 -0
- package/dist/src/http/client.js +26 -0
- package/dist/src/index.js +8 -7
- package/dist/src/lib/config.js +8 -8
- package/dist/src/lib/contentRepo.js +49 -0
- package/dist/src/render/table.js +7 -0
- package/dist/src/render/text.js +68 -10
- package/dist/src/types/labNotes.js +96 -12
- package/package.json +3 -1
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
/* ===========================================================
|
|
2
|
-
|
|
2
|
+
🦊 THE HUMAN PATTERN LAB — HPL CLI
|
|
3
|
+
-----------------------------------------------------------
|
|
4
|
+
File: get.ts
|
|
5
|
+
Role: Notes subcommand: `hpl notes get <slug>`
|
|
6
|
+
Author: Ada (The Human Pattern Lab)
|
|
7
|
+
Assistant: Lyric
|
|
8
|
+
Lab Unit: SCMS — Systems & Code Management Suite
|
|
9
|
+
Status: Active
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
Design:
|
|
12
|
+
- Core function returns { envelope, exitCode }
|
|
13
|
+
- Commander adapter decides json vs human rendering
|
|
14
|
+
- Markdown is canonical (content_markdown)
|
|
3
15
|
=========================================================== */
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import { getOutputMode, printJson } from "../../cli/output.js";
|
|
18
|
+
import { renderText } from "../../render/text.js";
|
|
19
|
+
import { LabNoteDetailSchema } from "../../types/labNotes.js";
|
|
20
|
+
import { getAlphaIntent } from "../../contract/intents.js";
|
|
21
|
+
import { ok, err } from "../../contract/envelope.js";
|
|
22
|
+
import { EXIT } from "../../contract/exitCodes.js";
|
|
23
|
+
import { getJson, HttpError } from "../../http/client.js";
|
|
24
|
+
/**
|
|
25
|
+
* Core: fetch a single published Lab Note (detail).
|
|
26
|
+
* Returns structured envelope + exitCode (no printing here).
|
|
27
|
+
*/
|
|
28
|
+
export async function runNotesGet(slug, commandName = "notes.get") {
|
|
10
29
|
const intent = getAlphaIntent("render_lab_note");
|
|
11
30
|
try {
|
|
12
31
|
const payload = await getJson(`/lab-notes/${encodeURIComponent(slug)}`);
|
|
13
|
-
const parsed =
|
|
32
|
+
const parsed = LabNoteDetailSchema.safeParse(payload);
|
|
14
33
|
if (!parsed.success) {
|
|
15
34
|
return {
|
|
16
35
|
envelope: err(commandName, intent, {
|
|
@@ -21,14 +40,16 @@ export async function runNotesGet(slug, commandName = "notes get") {
|
|
|
21
40
|
exitCode: EXIT.CONTRACT,
|
|
22
41
|
};
|
|
23
42
|
}
|
|
24
|
-
|
|
25
|
-
return { envelope: ok(commandName, intent, note), exitCode: EXIT.OK };
|
|
43
|
+
return { envelope: ok(commandName, intent, parsed.data), exitCode: EXIT.OK };
|
|
26
44
|
}
|
|
27
45
|
catch (e) {
|
|
28
46
|
if (e instanceof HttpError) {
|
|
29
|
-
if (e.status
|
|
47
|
+
if (e.status === 404) {
|
|
30
48
|
return {
|
|
31
|
-
envelope: err(commandName, intent, {
|
|
49
|
+
envelope: err(commandName, intent, {
|
|
50
|
+
code: "E_NOT_FOUND",
|
|
51
|
+
message: `No lab note found for slug: ${slug}`,
|
|
52
|
+
}),
|
|
32
53
|
exitCode: EXIT.NOT_FOUND,
|
|
33
54
|
};
|
|
34
55
|
}
|
|
@@ -46,3 +67,22 @@ export async function runNotesGet(slug, commandName = "notes get") {
|
|
|
46
67
|
return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN };
|
|
47
68
|
}
|
|
48
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Commander: `hpl notes get <slug>`
|
|
72
|
+
*/
|
|
73
|
+
export function notesGetSubcommand() {
|
|
74
|
+
return new Command("get")
|
|
75
|
+
.description("Get a Lab Note by slug (contract: render_lab_note)")
|
|
76
|
+
.argument("<slug>", "Lab Note slug")
|
|
77
|
+
.action(async (slug, opts, cmd) => {
|
|
78
|
+
const mode = getOutputMode(cmd);
|
|
79
|
+
const { envelope, exitCode } = await runNotesGet(slug, "notes.get");
|
|
80
|
+
if (mode === "json") {
|
|
81
|
+
printJson(envelope);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
renderText(envelope);
|
|
85
|
+
}
|
|
86
|
+
process.exitCode = exitCode;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
/* ===========================================================
|
|
2
|
-
|
|
2
|
+
🦊 THE HUMAN PATTERN LAB — HPL CLI
|
|
3
|
+
-----------------------------------------------------------
|
|
4
|
+
File: list.ts
|
|
5
|
+
Role: Notes subcommand: `hpl notes list`
|
|
6
|
+
Author: Ada (The Human Pattern Lab)
|
|
7
|
+
Assistant: Lyric
|
|
8
|
+
Lab Unit: SCMS — Systems & Code Management Suite
|
|
9
|
+
Status: Active
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
Design:
|
|
12
|
+
- Core function returns { envelope, exitCode }
|
|
13
|
+
- Commander adapter decides json vs human rendering
|
|
14
|
+
- JSON mode emits stdout-only structured data
|
|
3
15
|
=========================================================== */
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import { getOutputMode, printJson } from "../../cli/output.js";
|
|
18
|
+
import { formatTags, renderTable, safeLine } from "../../render/table.js";
|
|
19
|
+
import { renderText } from "../../render/text.js";
|
|
20
|
+
import { LabNotePreviewListSchema } from "../../types/labNotes.js";
|
|
21
|
+
import { getAlphaIntent } from "../../contract/intents.js";
|
|
22
|
+
import { ok, err } from "../../contract/envelope.js";
|
|
23
|
+
import { EXIT } from "../../contract/exitCodes.js";
|
|
24
|
+
import { getJson, HttpError } from "../../http/client.js";
|
|
25
|
+
/**
|
|
26
|
+
* Core: fetch the published lab note previews.
|
|
27
|
+
* Returns structured envelope + exitCode (no printing here).
|
|
28
|
+
*/
|
|
29
|
+
export async function runNotesList(commandName = "notes.list", locale) {
|
|
10
30
|
const intent = getAlphaIntent("render_lab_note");
|
|
11
31
|
try {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
32
|
+
const qp = locale ? `?locale=${encodeURIComponent(locale)}` : "";
|
|
33
|
+
const payload = await getJson(`/lab-notes${qp}`);
|
|
34
|
+
const parsed = LabNotePreviewListSchema.safeParse(payload);
|
|
14
35
|
if (!parsed.success) {
|
|
15
36
|
return {
|
|
16
37
|
envelope: err(commandName, intent, {
|
|
@@ -21,9 +42,7 @@ export async function runNotesList(commandName = "notes list") {
|
|
|
21
42
|
exitCode: EXIT.CONTRACT,
|
|
22
43
|
};
|
|
23
44
|
}
|
|
24
|
-
|
|
25
|
-
const notes = parsed.data;
|
|
26
|
-
return { envelope: ok(commandName, intent, { count: notes.length, notes }), exitCode: EXIT.OK };
|
|
45
|
+
return { envelope: ok(commandName, intent, parsed.data), exitCode: EXIT.OK };
|
|
27
46
|
}
|
|
28
47
|
catch (e) {
|
|
29
48
|
if (e instanceof HttpError) {
|
|
@@ -41,3 +60,40 @@ export async function runNotesList(commandName = "notes list") {
|
|
|
41
60
|
return { envelope: err(commandName, intent, { code: "E_UNKNOWN", message: msg }), exitCode: EXIT.UNKNOWN };
|
|
42
61
|
}
|
|
43
62
|
}
|
|
63
|
+
/* ----------------------------------------
|
|
64
|
+
Helper: human table renderer for notes
|
|
65
|
+
----------------------------------------- */
|
|
66
|
+
function renderNotesListTable(envelope) {
|
|
67
|
+
const rows = Array.isArray(envelope?.data) ? envelope.data : [];
|
|
68
|
+
const cols = [
|
|
69
|
+
{ header: "Title", width: 32, value: (r) => safeLine(r?.title ?? "-") },
|
|
70
|
+
{ header: "Slug", width: 26, value: (r) => safeLine(r?.slug ?? "-") },
|
|
71
|
+
{ header: "Locale", width: 6, value: (r) => safeLine(r?.locale ?? "-") },
|
|
72
|
+
{ header: "Type", width: 8, value: (r) => safeLine(r?.type ?? "-") },
|
|
73
|
+
{ header: "Tags", width: 24, value: (r) => formatTags(r?.tags) },
|
|
74
|
+
];
|
|
75
|
+
console.log(renderTable(rows, cols));
|
|
76
|
+
}
|
|
77
|
+
/* ----------------------------------------
|
|
78
|
+
Subcommand builder
|
|
79
|
+
----------------------------------------- */
|
|
80
|
+
export function notesListSubcommand() {
|
|
81
|
+
return new Command("list")
|
|
82
|
+
.description("List published Lab Notes (contract: render_lab_note)")
|
|
83
|
+
.action(async (opts, cmd) => {
|
|
84
|
+
const mode = getOutputMode(cmd);
|
|
85
|
+
const { envelope, exitCode } = await runNotesList("notes.list", opts.locale);
|
|
86
|
+
if (mode === "json") {
|
|
87
|
+
printJson(envelope);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
try {
|
|
91
|
+
renderNotesListTable(envelope);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
renderText(envelope);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
process.exitCode = exitCode;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🦊 THE HUMAN PATTERN LAB — HPL CLI
|
|
3
|
+
-----------------------------------------------------------
|
|
4
|
+
File: notes.ts
|
|
5
|
+
Role: Notes command assembler (domain root)
|
|
6
|
+
Author: Ada (The Human Pattern Lab)
|
|
7
|
+
Assistant: Lyric
|
|
8
|
+
Lab Unit: SCMS — Systems & Code Management Suite
|
|
9
|
+
Status: Active
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
Purpose:
|
|
12
|
+
Defines the `hpl notes` command tree and mounts subcommands:
|
|
13
|
+
- hpl notes list
|
|
14
|
+
- hpl notes get <slug>
|
|
15
|
+
- hpl notes sync
|
|
16
|
+
-----------------------------------------------------------
|
|
17
|
+
Design:
|
|
18
|
+
- This file is wiring only (no network calls, no rendering)
|
|
19
|
+
- Subcommands own their own output logic and contracts
|
|
20
|
+
=========================================================== */
|
|
21
|
+
import { Command } from "commander";
|
|
22
|
+
import { notesListSubcommand } from "./list.js";
|
|
23
|
+
import { notesGetSubcommand } from "./get.js";
|
|
24
|
+
import { notesSyncSubcommand } from "./notesSync.js";
|
|
25
|
+
import { notesCreateSubcommand } from "./create.js";
|
|
26
|
+
import { notesUpdateSubcommand } from "./update.js";
|
|
27
|
+
export function notesCommand() {
|
|
28
|
+
const notes = new Command("notes").description("Lab Notes commands");
|
|
29
|
+
// Subcommands
|
|
30
|
+
notes.addCommand(notesListSubcommand());
|
|
31
|
+
notes.addCommand(notesGetSubcommand());
|
|
32
|
+
notes.addCommand(notesCreateSubcommand());
|
|
33
|
+
notes.addCommand(notesUpdateSubcommand());
|
|
34
|
+
notes.addCommand(notesSyncSubcommand());
|
|
35
|
+
return notes;
|
|
36
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🦊 THE HUMAN PATTERN LAB — HPL CLI
|
|
3
|
+
-----------------------------------------------------------
|
|
4
|
+
File: notesSync.ts
|
|
5
|
+
Role: Notes subcommand: `hpl notes sync`
|
|
6
|
+
Author: Ada (The Human Pattern Lab)
|
|
7
|
+
Assistant: Lyric
|
|
8
|
+
Lab Unit: SCMS — Systems & Code Management Suite
|
|
9
|
+
Status: Active
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
Purpose:
|
|
12
|
+
Sync local markdown Lab Notes to the Lab API with predictable
|
|
13
|
+
behavior in both human and automation contexts.
|
|
14
|
+
|
|
15
|
+
Supports content-ledger workflows via --content-repo (clones
|
|
16
|
+
the content repo locally, then syncs from it).
|
|
17
|
+
-----------------------------------------------------------
|
|
18
|
+
Key Behaviors:
|
|
19
|
+
- Human mode: readable progress + summaries
|
|
20
|
+
- JSON mode (--json): stdout emits ONLY valid JSON (contract)
|
|
21
|
+
- Errors: stderr only
|
|
22
|
+
- Exit codes: deterministic (non-zero only on failure)
|
|
23
|
+
=========================================================== */
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { Command } from "commander";
|
|
27
|
+
import { HPL_BASE_URL, HPL_TOKEN } from "../../lib/config.js";
|
|
28
|
+
import { httpJson } from "../../lib/http.js";
|
|
29
|
+
import { listMarkdownFiles, readNote } from "../../lib/notes.js";
|
|
30
|
+
import { getOutputMode, printJson } from "../../cli/output.js";
|
|
31
|
+
import { buildSyncReport } from "../../cli/outputContract.js";
|
|
32
|
+
import { resolveContentRepo } from "../../lib/contentRepo.js";
|
|
33
|
+
import { LabNoteUpsertSchema } from "../../types/labNotes.js";
|
|
34
|
+
async function upsertNote(baseUrl, token, note, locale) {
|
|
35
|
+
const payload = {
|
|
36
|
+
slug: note.slug,
|
|
37
|
+
title: note.attributes.title,
|
|
38
|
+
markdown: note.markdown,
|
|
39
|
+
locale,
|
|
40
|
+
// Optional fields if your note parser provides them
|
|
41
|
+
subtitle: note.attributes.subtitle,
|
|
42
|
+
summary: note.attributes.summary,
|
|
43
|
+
tags: note.attributes.tags,
|
|
44
|
+
published: note.attributes.published,
|
|
45
|
+
status: note.attributes.status,
|
|
46
|
+
type: note.attributes.type,
|
|
47
|
+
dept: note.attributes.dept,
|
|
48
|
+
};
|
|
49
|
+
const parsed = LabNoteUpsertSchema.safeParse(payload);
|
|
50
|
+
if (!parsed.success) {
|
|
51
|
+
throw new Error(`Invalid LabNoteUpsertPayload: ${parsed.error.message}`);
|
|
52
|
+
}
|
|
53
|
+
return httpJson({ baseUrl, token }, "POST", "/lab-notes/upsert", parsed.data);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Commander: `hpl notes sync`
|
|
57
|
+
*/
|
|
58
|
+
export function notesSyncSubcommand() {
|
|
59
|
+
return new Command("sync")
|
|
60
|
+
.description("Sync markdown notes to the API")
|
|
61
|
+
// IMPORTANT: do NOT default --dir here (it conflicts with repo-first flows)
|
|
62
|
+
.option("--dir <path>", "Directory containing markdown notes")
|
|
63
|
+
.option("--content-repo <repo>", "GitHub owner/name or URL for Lab Notes content repo (or HPL_CONTENT_REPO env)")
|
|
64
|
+
.option("--content-ref <ref>", "Branch, tag, or SHA to checkout (default: main)")
|
|
65
|
+
.option("--content-subdir <path>", "Subdirectory inside repo containing labnotes (default: labnotes)")
|
|
66
|
+
.option("--cache-dir <path>", "Local cache directory for cloned content repos")
|
|
67
|
+
.option("--locale <code>", "Locale code", "en")
|
|
68
|
+
.option("--base-url <url>", "Override API base URL (ex: https://api.thehumanpatternlab.com)")
|
|
69
|
+
.option("--dry-run", "Print what would be sent, but do not call the API", false)
|
|
70
|
+
.option("--only <slug>", "Sync only a single note by slug")
|
|
71
|
+
.option("--limit <n>", "Sync only the first N notes", (v) => parseInt(v, 10))
|
|
72
|
+
.action(async (opts, cmd) => {
|
|
73
|
+
const mode = getOutputMode(cmd); // "json" | "human"
|
|
74
|
+
const jsonError = (message, extra) => {
|
|
75
|
+
if (mode === "json") {
|
|
76
|
+
process.stderr.write(JSON.stringify({ ok: false, error: { message, extra } }, null, 2) + "\n");
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.error(message);
|
|
80
|
+
if (extra)
|
|
81
|
+
console.error(extra);
|
|
82
|
+
}
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
};
|
|
85
|
+
// -----------------------------
|
|
86
|
+
// Resolve content source → rootDir
|
|
87
|
+
// -----------------------------
|
|
88
|
+
const envRepo = String(process.env.SKULK_CONTENT_REPO ?? "").trim();
|
|
89
|
+
const repoArg = String(opts.contentRepo ?? "").trim() || envRepo;
|
|
90
|
+
const dirRaw = String(opts.dir ?? "").trim();
|
|
91
|
+
const dirArg = dirRaw || (!repoArg ? "./src/labnotes/en" : "");
|
|
92
|
+
const ref = String(opts.contentRef ?? "main").trim() || "main";
|
|
93
|
+
const subdir = String(opts.contentSubdir ?? "labnotes").trim() || "labnotes";
|
|
94
|
+
const cacheDir = String(opts.cacheDir ?? "").trim() || undefined;
|
|
95
|
+
if (dirArg && repoArg) {
|
|
96
|
+
jsonError("Use only one content source: either --dir OR --content-repo (or SKULK_CONTENT_REPO), not both.", { dir: dirArg, contentRepo: repoArg });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
let rootDir;
|
|
100
|
+
let source;
|
|
101
|
+
try {
|
|
102
|
+
if (repoArg) {
|
|
103
|
+
const resolved = await resolveContentRepo({
|
|
104
|
+
repo: repoArg,
|
|
105
|
+
ref,
|
|
106
|
+
cacheDir,
|
|
107
|
+
quietStdout: mode === "json", // keep stdout clean for JSON mode
|
|
108
|
+
});
|
|
109
|
+
rootDir = path.join(resolved.dir, subdir);
|
|
110
|
+
source = { kind: "repo", repo: repoArg, ref, subdir, dir: rootDir };
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
rootDir = dirArg;
|
|
114
|
+
source = { kind: "dir", dir: rootDir };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
jsonError("Failed to resolve content source.", {
|
|
119
|
+
error: String(e),
|
|
120
|
+
repo: repoArg || undefined,
|
|
121
|
+
dir: dirArg || undefined,
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!fs.existsSync(rootDir)) {
|
|
126
|
+
jsonError(`Notes directory not found: ${rootDir}`, {
|
|
127
|
+
hintRepo: "If using repo mode, verify the repo contains labnotes/<locale>/",
|
|
128
|
+
hintDir: `Try: hpl notes sync --dir "..\\\\the-human-pattern-lab\\\\src\\\\labnotes\\\\en"`,
|
|
129
|
+
source,
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// -----------------------------
|
|
134
|
+
// Continue with existing sync flow
|
|
135
|
+
// -----------------------------
|
|
136
|
+
const baseUrl = HPL_BASE_URL(opts.baseUrl);
|
|
137
|
+
const token = HPL_TOKEN();
|
|
138
|
+
const files = listMarkdownFiles(rootDir);
|
|
139
|
+
let selectedFiles = files;
|
|
140
|
+
if (opts.only) {
|
|
141
|
+
selectedFiles = files.filter((f) => f.toLowerCase().includes(String(opts.only).toLowerCase()));
|
|
142
|
+
}
|
|
143
|
+
if (opts.limit && Number.isFinite(opts.limit)) {
|
|
144
|
+
selectedFiles = selectedFiles.slice(0, opts.limit);
|
|
145
|
+
}
|
|
146
|
+
if (selectedFiles.length === 0) {
|
|
147
|
+
if (mode === "json") {
|
|
148
|
+
printJson({
|
|
149
|
+
ok: true,
|
|
150
|
+
action: "noop",
|
|
151
|
+
message: "No matching notes found.",
|
|
152
|
+
matched: 0,
|
|
153
|
+
source,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
console.log("No matching notes found.");
|
|
158
|
+
}
|
|
159
|
+
process.exitCode = 0;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (mode === "human") {
|
|
163
|
+
console.log(`HPL syncing ${selectedFiles.length} note(s) from ${rootDir}`);
|
|
164
|
+
if (source.kind === "repo") {
|
|
165
|
+
console.log(`Content Repo: ${source.repo} @ ${source.ref} (${source.subdir})`);
|
|
166
|
+
}
|
|
167
|
+
console.log(`API: ${baseUrl}`);
|
|
168
|
+
console.log(`Locale: ${opts.locale}`);
|
|
169
|
+
console.log(opts.dryRun ? "Mode: DRY RUN (no writes)" : "Mode: LIVE (writing)");
|
|
170
|
+
}
|
|
171
|
+
const results = [];
|
|
172
|
+
for (const file of selectedFiles) {
|
|
173
|
+
try {
|
|
174
|
+
const note = readNote(file, opts.locale);
|
|
175
|
+
if (opts.dryRun) {
|
|
176
|
+
results.push({ file, slug: note.slug, status: "dry-run" });
|
|
177
|
+
if (mode === "human") {
|
|
178
|
+
console.log(`\n---\n${note.slug}\n${file}\nfrontmatter keys: ${Object.keys(note.attributes).join(", ")}`);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const res = await upsertNote(baseUrl, token, note, opts.locale);
|
|
183
|
+
results.push({ file, slug: note.slug, status: "ok", action: res.action });
|
|
184
|
+
if (mode === "human") {
|
|
185
|
+
console.log(`✅ ${note.slug} (${res.action ?? "ok"})`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
const msg = String(e);
|
|
190
|
+
results.push({ file, status: "fail", error: msg });
|
|
191
|
+
if (mode === "human") {
|
|
192
|
+
console.error(`❌ ${file}`);
|
|
193
|
+
console.error(msg);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const report = buildSyncReport({
|
|
198
|
+
results,
|
|
199
|
+
dryRun: Boolean(opts.dryRun),
|
|
200
|
+
locale: opts.locale,
|
|
201
|
+
baseUrl,
|
|
202
|
+
});
|
|
203
|
+
if (mode === "json") {
|
|
204
|
+
// Attach source without rewriting your contract builder (minimal + safe).
|
|
205
|
+
printJson({ ...report, source });
|
|
206
|
+
if (!report.ok)
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const { synced, dryRun, failed } = report.summary;
|
|
211
|
+
if (report.dryRun) {
|
|
212
|
+
console.log(`\nDone. ${dryRun} note(s) would be synced (dry-run). Failures: ${failed}`);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
console.log(`\nDone. ${synced} note(s) synced successfully. Failures: ${failed}`);
|
|
216
|
+
}
|
|
217
|
+
if (!report.ok)
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/* ===========================================================
|
|
2
|
+
🦊 THE HUMAN PATTERN LAB — HPL CLI
|
|
3
|
+
-----------------------------------------------------------
|
|
4
|
+
File: update.ts
|
|
5
|
+
Role: Notes subcommand: `hpl notes update`
|
|
6
|
+
Author: Ada (The Human Pattern Lab)
|
|
7
|
+
Assistant: Claude
|
|
8
|
+
Lab Unit: SCMS — Systems & Code Management Suite
|
|
9
|
+
Status: Active
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
Purpose:
|
|
12
|
+
Update an existing Lab Note via API using the upsert endpoint.
|
|
13
|
+
Supports both markdown file input and inline content.
|
|
14
|
+
-----------------------------------------------------------
|
|
15
|
+
Design:
|
|
16
|
+
- Core function returns { envelope, exitCode }
|
|
17
|
+
- Commander adapter decides json vs human rendering
|
|
18
|
+
- Requires authentication via HPL_TOKEN
|
|
19
|
+
- Uses upsert endpoint (same as create)
|
|
20
|
+
=========================================================== */
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import { Command } from "commander";
|
|
23
|
+
import { getOutputMode, printJson } from "../../cli/output.js";
|
|
24
|
+
import { renderText } from "../../render/text.js";
|
|
25
|
+
import { LabNoteUpsertSchema } from "../../types/labNotes.js";
|
|
26
|
+
import { HPL_TOKEN } from "../../lib/config.js";
|
|
27
|
+
import { getAlphaIntent } from "../../contract/intents.js";
|
|
28
|
+
import { ok, err } from "../../contract/envelope.js";
|
|
29
|
+
import { EXIT } from "../../contract/exitCodes.js";
|
|
30
|
+
import { postJson, HttpError } from "../../http/client.js";
|
|
31
|
+
/**
|
|
32
|
+
* Core: update an existing Lab Note.
|
|
33
|
+
* Returns structured envelope + exitCode (no printing here).
|
|
34
|
+
*/
|
|
35
|
+
export async function runNotesUpdate(options, commandName = "notes.update") {
|
|
36
|
+
const intent = getAlphaIntent("update_lab_note");
|
|
37
|
+
// Authentication check
|
|
38
|
+
const token = HPL_TOKEN();
|
|
39
|
+
if (!token) {
|
|
40
|
+
return {
|
|
41
|
+
envelope: err(commandName, intent, {
|
|
42
|
+
code: "E_AUTH",
|
|
43
|
+
message: "Authentication required. Set HPL_TOKEN environment variable or configure token in ~/.humanpatternlab/hpl.json",
|
|
44
|
+
}),
|
|
45
|
+
exitCode: EXIT.AUTH,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Get markdown content if provided
|
|
49
|
+
let markdown;
|
|
50
|
+
if (options.file) {
|
|
51
|
+
if (!fs.existsSync(options.file)) {
|
|
52
|
+
return {
|
|
53
|
+
envelope: err(commandName, intent, {
|
|
54
|
+
code: "E_NOT_FOUND",
|
|
55
|
+
message: `File not found: ${options.file}`,
|
|
56
|
+
}),
|
|
57
|
+
exitCode: EXIT.NOT_FOUND,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
markdown = fs.readFileSync(options.file, "utf-8");
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
65
|
+
return {
|
|
66
|
+
envelope: err(commandName, intent, {
|
|
67
|
+
code: "E_IO",
|
|
68
|
+
message: `Failed to read file: ${msg}`,
|
|
69
|
+
}),
|
|
70
|
+
exitCode: EXIT.IO,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (options.markdown) {
|
|
75
|
+
markdown = options.markdown;
|
|
76
|
+
}
|
|
77
|
+
// For updates, we need at least title OR markdown
|
|
78
|
+
if (!options.title && !markdown) {
|
|
79
|
+
return {
|
|
80
|
+
envelope: err(commandName, intent, {
|
|
81
|
+
code: "E_VALIDATION",
|
|
82
|
+
message: "Must provide at least --title or --markdown/--file for update",
|
|
83
|
+
}),
|
|
84
|
+
exitCode: EXIT.VALIDATION,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Build payload - use required fields from what's provided
|
|
88
|
+
// The API will handle partial updates if it supports them,
|
|
89
|
+
// or we provide what we have
|
|
90
|
+
const payload = {
|
|
91
|
+
slug: options.slug,
|
|
92
|
+
};
|
|
93
|
+
if (options.title)
|
|
94
|
+
payload.title = options.title;
|
|
95
|
+
if (markdown)
|
|
96
|
+
payload.markdown = markdown;
|
|
97
|
+
if (options.locale)
|
|
98
|
+
payload.locale = options.locale;
|
|
99
|
+
if (options.subtitle)
|
|
100
|
+
payload.subtitle = options.subtitle;
|
|
101
|
+
if (options.summary)
|
|
102
|
+
payload.summary = options.summary;
|
|
103
|
+
if (options.tags)
|
|
104
|
+
payload.tags = options.tags;
|
|
105
|
+
if (options.published)
|
|
106
|
+
payload.published = options.published;
|
|
107
|
+
if (options.status)
|
|
108
|
+
payload.status = options.status;
|
|
109
|
+
if (options.type)
|
|
110
|
+
payload.type = options.type;
|
|
111
|
+
if (options.dept)
|
|
112
|
+
payload.dept = options.dept;
|
|
113
|
+
// The upsert endpoint requires title and markdown
|
|
114
|
+
// For an update operation, if these aren't provided, we should fetch the existing note first
|
|
115
|
+
if (!payload.title || !payload.markdown) {
|
|
116
|
+
return {
|
|
117
|
+
envelope: err(commandName, intent, {
|
|
118
|
+
code: "E_VALIDATION",
|
|
119
|
+
message: "Update requires both --title and (--markdown or --file). For partial updates, use the API directly or fetch the existing note first.",
|
|
120
|
+
}),
|
|
121
|
+
exitCode: EXIT.VALIDATION,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Validate payload
|
|
125
|
+
const parsed = LabNoteUpsertSchema.safeParse(payload);
|
|
126
|
+
if (!parsed.success) {
|
|
127
|
+
return {
|
|
128
|
+
envelope: err(commandName, intent, {
|
|
129
|
+
code: "E_VALIDATION",
|
|
130
|
+
message: "Invalid note data",
|
|
131
|
+
details: parsed.error.flatten(),
|
|
132
|
+
}),
|
|
133
|
+
exitCode: EXIT.VALIDATION,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Make API request
|
|
137
|
+
try {
|
|
138
|
+
const response = await postJson("/lab-notes/upsert", parsed.data, token);
|
|
139
|
+
return {
|
|
140
|
+
envelope: ok(commandName, intent, {
|
|
141
|
+
slug: response.slug,
|
|
142
|
+
action: response.action ?? "updated",
|
|
143
|
+
message: `Lab Note ${response.action ?? "updated"}: ${response.slug}`,
|
|
144
|
+
}),
|
|
145
|
+
exitCode: EXIT.OK,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
if (e instanceof HttpError) {
|
|
150
|
+
if (e.status === 401 || e.status === 403) {
|
|
151
|
+
return {
|
|
152
|
+
envelope: err(commandName, intent, {
|
|
153
|
+
code: "E_AUTH",
|
|
154
|
+
message: "Authentication failed. Check your HPL_TOKEN.",
|
|
155
|
+
}),
|
|
156
|
+
exitCode: EXIT.AUTH,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (e.status === 404) {
|
|
160
|
+
return {
|
|
161
|
+
envelope: err(commandName, intent, {
|
|
162
|
+
code: "E_NOT_FOUND",
|
|
163
|
+
message: `No lab note found for slug: ${options.slug}`,
|
|
164
|
+
}),
|
|
165
|
+
exitCode: EXIT.NOT_FOUND,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const code = e.status && e.status >= 500 ? "E_SERVER" : "E_HTTP";
|
|
169
|
+
return {
|
|
170
|
+
envelope: err(commandName, intent, {
|
|
171
|
+
code,
|
|
172
|
+
message: `API request failed (${e.status ?? "unknown"})`,
|
|
173
|
+
details: e.body ? e.body.slice(0, 500) : undefined,
|
|
174
|
+
}),
|
|
175
|
+
exitCode: e.status && e.status >= 500 ? EXIT.SERVER : EXIT.NETWORK,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
179
|
+
return {
|
|
180
|
+
envelope: err(commandName, intent, {
|
|
181
|
+
code: "E_UNKNOWN",
|
|
182
|
+
message: msg,
|
|
183
|
+
}),
|
|
184
|
+
exitCode: EXIT.UNKNOWN,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Commander: `hpl notes update`
|
|
190
|
+
*/
|
|
191
|
+
export function notesUpdateSubcommand() {
|
|
192
|
+
return new Command("update")
|
|
193
|
+
.description("Update an existing Lab Note (contract: update_lab_note)")
|
|
194
|
+
.argument("<slug>", "Note slug to update")
|
|
195
|
+
.option("--title <title>", "Note title")
|
|
196
|
+
.option("--markdown <text>", "Markdown content (inline)")
|
|
197
|
+
.option("--file <path>", "Path to markdown file")
|
|
198
|
+
.option("--locale <code>", "Locale code")
|
|
199
|
+
.option("--subtitle <text>", "Note subtitle")
|
|
200
|
+
.option("--summary <text>", "Note summary")
|
|
201
|
+
.option("--tags <tags>", "Comma-separated tags", (val) => val.split(",").map((t) => t.trim()))
|
|
202
|
+
.option("--published <date>", "Publication date (ISO format)")
|
|
203
|
+
.option("--status <status>", "Note status (draft|published|archived)")
|
|
204
|
+
.option("--type <type>", "Note type (labnote|paper|memo|lore|weather)")
|
|
205
|
+
.option("--dept <dept>", "Department code")
|
|
206
|
+
.action(async (slug, opts, cmd) => {
|
|
207
|
+
const mode = getOutputMode(cmd);
|
|
208
|
+
const { envelope, exitCode } = await runNotesUpdate({ ...opts, slug }, "notes.update");
|
|
209
|
+
if (mode === "json") {
|
|
210
|
+
printJson(envelope);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
renderText(envelope);
|
|
214
|
+
}
|
|
215
|
+
process.exitCode = exitCode;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -58,7 +58,7 @@ export function notesSyncCommand() {
|
|
|
58
58
|
.option('--dir <path>', 'Directory containing markdown notes', './src/labnotes/en')
|
|
59
59
|
//.option("--dir <path>", "Directory containing markdown notes", "./labnotes/en")
|
|
60
60
|
.option('--locale <code>', 'Locale code', 'en')
|
|
61
|
-
.option('--base-url <url>', 'Override API base URL (ex: https://thehumanpatternlab.com
|
|
61
|
+
.option('--base-url <url>', 'Override API base URL (ex: https://api.thehumanpatternlab.com)')
|
|
62
62
|
.option('--dry-run', 'Print what would be sent, but do not call the API', false)
|
|
63
63
|
.option('--only <slug>', 'Sync only a single note by slug')
|
|
64
64
|
.option('--limit <n>', 'Sync only the first N notes', (v) => parseInt(v, 10))
|