@thehumanpatternlab/skulk 0.1.1
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 +115 -0
- package/dist/cli/output.js +21 -0
- package/dist/cli/outputContract.js +32 -0
- package/dist/commands/notesSync.js +191 -0
- package/dist/index.js +36 -0
- package/dist/lib/config.js +52 -0
- package/dist/lib/http.js +18 -0
- package/dist/lib/notes.js +62 -0
- package/dist/sdk/LabClient.js +94 -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 +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Skulk CLI
|
|
3
|
+
The Human Pattern Lab
|
|
4
|
+
|
|
5
|
+
This README is written for humans.
|
|
6
|
+
Design rationale lives in DESIGN.md.
|
|
7
|
+
-->
|
|
8
|
+
# Skulk CLI
|
|
9
|
+
|
|
10
|
+
[](./DESIGN.md) 
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
> A modern, automation-safe CLI for The Human Pattern Lab.
|
|
15
|
+
|
|
16
|
+
Skulk is a command-line tool for syncing and managing Lab Notes — built to work just as well for humans at the keyboard as it does for automation, CI, and agent-driven workflows.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
## What Skulk Connects To
|
|
20
|
+
|
|
21
|
+
Skulk is the CLI for **The Human Pattern Lab API**.
|
|
22
|
+
|
|
23
|
+
By default it targets a Human Pattern Lab API instance. You can override the API endpoint with `--base-url` to use staging or a self-hosted deployment of the same API.
|
|
24
|
+
|
|
25
|
+
> Note: `--base-url` is intended for alternate deployments of the Human Pattern Lab API, not arbitrary third-party APIs.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
### Environment variables
|
|
31
|
+
|
|
32
|
+
- `SKULK_TOKEN` — API token used to authenticate requests.
|
|
33
|
+
- `SKULK_BASE_URL` — Base URL for a Human Pattern Lab API instance (overridden by `--base-url`).
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export SKULK_TOKEN="..."
|
|
39
|
+
export SKULK_BASE_URL="https://thehumanpatternlab.com/api"
|
|
40
|
+
skulk notes sync --dir ./src/labnotes/en
|
|
41
|
+
```
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Why Skulk Exists
|
|
45
|
+
|
|
46
|
+
Command-line tools no longer live in a human-only world.
|
|
47
|
+
|
|
48
|
+
They’re run by:
|
|
49
|
+
- developers exploring and iterating
|
|
50
|
+
- scripts and CI pipelines
|
|
51
|
+
- automation layers and AI-assisted workflows
|
|
52
|
+
|
|
53
|
+
Skulk was designed from the start to behave **predictably and honestly** in all of those contexts — without sacrificing human usability.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 🤖 AI-Forward (for Humans)
|
|
58
|
+
|
|
59
|
+
Skulk follows **AI-forward engineering principles** — not for AIs, but for the people who build tools that increasingly interact with them.
|
|
60
|
+
|
|
61
|
+
### Dual Output Modes
|
|
62
|
+
|
|
63
|
+
By default, Skulk is human-readable:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
skulk notes sync
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
When `--json` is enabled:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
skulk --json notes sync
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Skulk switches to machine-readable output:
|
|
76
|
+
- stdout contains **only valid JSON**
|
|
77
|
+
- no banners, emojis, or progress chatter
|
|
78
|
+
- errors go to stderr
|
|
79
|
+
- exit codes are deterministic
|
|
80
|
+
|
|
81
|
+
This makes Skulk safe to use in:
|
|
82
|
+
- scripts
|
|
83
|
+
- CI pipelines
|
|
84
|
+
- automation
|
|
85
|
+
- AI-assisted workflows
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## JSON Output Contract
|
|
90
|
+
|
|
91
|
+
Structured output is treated as a **contract**, not a courtesy.
|
|
92
|
+
|
|
93
|
+
The repository includes a built-in verification:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm run json:check
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This command runs Skulk in `--json` mode and fails immediately if *any* non-JSON output appears on stdout.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Design & Philosophy
|
|
104
|
+
|
|
105
|
+
Curious why Skulk works this way?
|
|
106
|
+
→ [DESIGN.md](./DESIGN.md)
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Philosophy
|
|
111
|
+
|
|
112
|
+
> Automation shouldn’t require guessing what a tool *meant* to say.
|
|
113
|
+
|
|
114
|
+
Skulk is boring in the best way:
|
|
115
|
+
predictable, explicit, and dependable.
|
|
@@ -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,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
|
+
}
|
package/dist/index.js
ADDED
|
@@ -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);
|
|
@@ -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
|
+
}
|
package/dist/lib/http.js
ADDED
|
@@ -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,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,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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thehumanpatternlab/skulk",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "CLI for syncing Lab Notes with The Human Pattern Lab API",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"cli",
|
|
8
|
+
"automation",
|
|
9
|
+
"human-pattern-lab"
|
|
10
|
+
],
|
|
11
|
+
"author": "Ada Vale",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"skulk": "dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/index.js",
|
|
19
|
+
"dist/lab.js",
|
|
20
|
+
"dist/cli/**",
|
|
21
|
+
"dist/commands/**",
|
|
22
|
+
"dist/lib/**",
|
|
23
|
+
"dist/sdk/**",
|
|
24
|
+
"dist/sync/**",
|
|
25
|
+
"dist/utils/**"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "tsx src/index.ts",
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"build:watch": "tsc -w",
|
|
31
|
+
"json:check": "skulk --json notes sync --dry-run --dir \"../the-human-pattern-lab/src/labnotes/en\" | node -e \"JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('JSON output is clean ✅')\"",
|
|
32
|
+
"json:check:pretty": "npm run json:check && echo \"JSON output is clean ✅\"",
|
|
33
|
+
"start": "node dist/index.js",
|
|
34
|
+
"test": "vitest",
|
|
35
|
+
"test:run": "vitest run",
|
|
36
|
+
"test:watch": "vitest watch",
|
|
37
|
+
"link": "npm run build && npm link"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"commander": "^11.1.0",
|
|
41
|
+
"gray-matter": "^4.0.3",
|
|
42
|
+
"node-fetch": "^3.3.2",
|
|
43
|
+
"zod": "^4.2.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^25.0.3",
|
|
47
|
+
"husky": "^9.1.7",
|
|
48
|
+
"lint-staged": "^16.2.7",
|
|
49
|
+
"prettier": "^3.7.4",
|
|
50
|
+
"tsx": "^4.21.0",
|
|
51
|
+
"vitest": "^4.0.16",
|
|
52
|
+
"typescript": "^5.9.3"
|
|
53
|
+
},
|
|
54
|
+
"husky": {
|
|
55
|
+
"hooks": {
|
|
56
|
+
"pre-commit": "lint-staged"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"lint-staged": {
|
|
60
|
+
"*.{js,ts,md,json}": "prettier --write"
|
|
61
|
+
}
|
|
62
|
+
}
|