@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.
@@ -1,16 +1,35 @@
1
1
  /* ===========================================================
2
- 🌌 HUMAN PATTERN LAB — COMMAND: notes get <slug>
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 { 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") {
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 = LabNoteSchema.safeParse(payload);
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
- const note = parsed.data;
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 == 404) {
47
+ if (e.status === 404) {
30
48
  return {
31
- envelope: err(commandName, intent, { code: "E_NOT_FOUND", message: `No lab note found for slug: ${slug}` }),
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
- 🌌 HUMAN PATTERN LAB — COMMAND: notes list
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 { 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") {
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 payload = await getJson("/lab-notes");
13
- const parsed = LabNoteListSchema.safeParse(payload);
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
- // 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 };
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/api)')
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))