@kaydash9999/banker-plugins 0.4.0 → 0.6.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +23 -2
- package/codex/manifest.json +12 -0
- package/codex/transform-matrix.md +1 -0
- package/commands/setup.md +28 -16
- package/hooks/hooks.json +17 -0
- package/hooks/obsidize-hook.mjs +150 -0
- package/hooks/run.cjs +45 -0
- package/package.json +3 -1
- package/skills/harness-factory/SKILL.md +114 -0
- package/skills/obsidizer/SKILL.md +255 -0
- package/skills/obsidizer/obsidize.mjs +554 -0
- package/skills/setup-java/SKILL.md +113 -0
- package/skills/setup-lsp/SKILL.md +232 -0
- package/skills/setup-mcp/SKILL.md +156 -0
- package/skills/setup-node/SKILL.md +112 -0
- package/skills/setup-pwsh/SKILL.md +106 -0
- package/skills/setup-python/SKILL.md +121 -0
- package/skills/setup-sandbox/SKILL.md +202 -0
- package/skills/setup-tmux/SKILL.md +105 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* obsidizer canonicalizer: markdown -> canonical markdown, zero per-page input.
|
|
4
|
+
*
|
|
5
|
+
* Byte-deterministic and idempotent by construction: a 2nd run is a no-op.
|
|
6
|
+
* Pure filesystem, zero deps, node >=16.7. The LLM makes the semantic edits
|
|
7
|
+
* (which mentions to link, which backlinks are genuine) BEFORE this runs; this
|
|
8
|
+
* file only makes the result canonical. It never calls a wiki_* tool.
|
|
9
|
+
*
|
|
10
|
+
* Write discipline (PM-7), inherited by both entry points (bare run and the
|
|
11
|
+
* --enable hook): an unchanged page is never written at all; a changed one is
|
|
12
|
+
* staged to a temp file, re-read and SKIPPED if it moved under us (CAS), then
|
|
13
|
+
* published by rename. A torn read makes a page vanish from OMC's index.md; a
|
|
14
|
+
* lost update silently eats a concurrent wiki_ingest merge. The rename kills
|
|
15
|
+
* the torn read outright; the CAS only narrows the lost-update window to that
|
|
16
|
+
* one syscall — a residual race remains, and is accepted (see writeIfUnchanged).
|
|
17
|
+
*
|
|
18
|
+
* Usage: node obsidize.mjs <path> [--profile=auto|omc|generic] [--dry-run] [--json]
|
|
19
|
+
*/
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const RESERVED_FILES = new Set(['index.md', 'log.md', 'environment.md']);
|
|
26
|
+
const FM_KEYS = ['title', 'tags', 'created', 'updated', 'sources', 'links', 'category', 'confidence', 'schemaVersion'];
|
|
27
|
+
const SCHEMA_VERSION = 1;
|
|
28
|
+
// Bounds GROWTH, not the footer: how many slugs obsidizer may ADD to one. It never costs a
|
|
29
|
+
// footer a link it already carries — see renderFooter.
|
|
30
|
+
const FOOTER_CAP = 12;
|
|
31
|
+
const FOOTER_PREFIX = '관련: ';
|
|
32
|
+
const FOOTER_SEP = ' · ';
|
|
33
|
+
// mergePage appends `\n\n---\n\n## Update (<ts>)\n\n...`; everything from here on is preserve-only.
|
|
34
|
+
const UPDATE_BOUNDARY = /\n\n---\n\n## Update \(/;
|
|
35
|
+
const TMP_SUFFIX = '.obsidizer.tmp';
|
|
36
|
+
// Any `[[...]]`, matching OMC's extractWikiLinks regex (ingest.ts:132-139) exactly.
|
|
37
|
+
const WIKILINK = /\[\[([^\]]+)\]\]/g;
|
|
38
|
+
// D8's positive whitelist, as a form test rather than "whatever names a file on disk".
|
|
39
|
+
// titleToSlug can only ever emit this shape, so it costs an OMC tree nothing.
|
|
40
|
+
const BARE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
41
|
+
// Opt-out marker. Obsidian renders `%%...%%` as a comment, so it is invisible in the vault.
|
|
42
|
+
const IGNORE_MARKER = '%%obsidizer:ignore%%';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Slug-ish normalizer for the alias divergence test. Deliberately WITHOUT
|
|
46
|
+
* titleToSlug's 64-char cap: the cap is an OMC filename artifact, and mirroring
|
|
47
|
+
* it here would suppress the alias that restores a cap-truncated title.
|
|
48
|
+
*/
|
|
49
|
+
export function norm(x) {
|
|
50
|
+
return String(x).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Escape a string for use inside YAML double quotes (mirrors OMC escapeYaml). */
|
|
54
|
+
function escapeYaml(s) {
|
|
55
|
+
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unescapeYaml(s) {
|
|
59
|
+
return s.replace(/\\(\\|"|n|r)/g, (_, ch) => (ch === 'n' ? '\n' : ch === 'r' ? '\r' : ch));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Line-based `key: value` parse, mirroring OMC parseSimpleYaml (a line with no `:` is skipped). */
|
|
63
|
+
function parseSimpleYaml(yaml) {
|
|
64
|
+
const map = {};
|
|
65
|
+
const order = [];
|
|
66
|
+
for (const line of yaml.split('\n')) {
|
|
67
|
+
const colonIdx = line.indexOf(':');
|
|
68
|
+
if (colonIdx === -1) continue;
|
|
69
|
+
const key = line.slice(0, colonIdx).trim();
|
|
70
|
+
if (!key) continue;
|
|
71
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
72
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
73
|
+
value = unescapeYaml(value.slice(1, -1));
|
|
74
|
+
}
|
|
75
|
+
if (!(key in map)) order.push(key);
|
|
76
|
+
map[key] = value;
|
|
77
|
+
}
|
|
78
|
+
return { map, order };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parse a FLOW YAML array `[a, b]` (mirrors OMC parseYamlArray — flow form only). */
|
|
82
|
+
function parseYamlArray(value) {
|
|
83
|
+
if (!value) return [];
|
|
84
|
+
const trimmed = value.trim();
|
|
85
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
86
|
+
return trimmed.slice(1, -1).split(',')
|
|
87
|
+
.map((s) => unescapeYaml(s.trim().replace(/^["']|["']$/g, '')))
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
return trimmed ? [trimmed] : [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split a `---\n...\n---\n` frontmatter block off the body.
|
|
95
|
+
* Returns null when the note has no frontmatter block (we never create one).
|
|
96
|
+
*/
|
|
97
|
+
export function parseFrontmatter(raw) {
|
|
98
|
+
const normalized = String(raw).replace(/\r\n/g, '\n');
|
|
99
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
100
|
+
if (!match) return null;
|
|
101
|
+
const { map, order } = parseSimpleYaml(match[1]);
|
|
102
|
+
return { lines: match[1].split('\n'), map, order, body: match[2] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The 9 OMC keys, parsed. Returns null unless every key is present — we never fabricate one. */
|
|
106
|
+
export function parseOmcFrontmatter(map) {
|
|
107
|
+
if (!FM_KEYS.every((k) => k in map)) return null;
|
|
108
|
+
return {
|
|
109
|
+
title: String(map.title || ''),
|
|
110
|
+
tags: parseYamlArray(map.tags),
|
|
111
|
+
created: String(map.created || ''),
|
|
112
|
+
updated: String(map.updated || ''),
|
|
113
|
+
sources: parseYamlArray(map.sources),
|
|
114
|
+
links: parseYamlArray(map.links),
|
|
115
|
+
category: String(map.category || 'reference'),
|
|
116
|
+
confidence: String(map.confidence || 'medium'),
|
|
117
|
+
schemaVersion: Number(map.schemaVersion) || SCHEMA_VERSION,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Emit EXACTLY the 9 keys in OMC's order (mirrors serializePage). Never add, drop, or reorder. */
|
|
122
|
+
export function serializeFrontmatter(fm) {
|
|
123
|
+
return [
|
|
124
|
+
`title: "${escapeYaml(fm.title)}"`,
|
|
125
|
+
`tags: [${fm.tags.map((t) => `"${escapeYaml(t)}"`).join(', ')}]`,
|
|
126
|
+
`created: ${fm.created}`,
|
|
127
|
+
`updated: ${fm.updated}`,
|
|
128
|
+
`sources: [${fm.sources.map((s) => `"${escapeYaml(s)}"`).join(', ')}]`,
|
|
129
|
+
`links: [${fm.links.map((l) => `"${escapeYaml(l)}"`).join(', ')}]`,
|
|
130
|
+
`category: ${fm.category}`,
|
|
131
|
+
`confidence: ${fm.confidence}`,
|
|
132
|
+
`schemaVersion: ${fm.schemaVersion}`,
|
|
133
|
+
].join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Walk lines tracking fenced-code state, so ``` blocks are never mistaken for prose. */
|
|
137
|
+
function walkOutsideFences(text, visit) {
|
|
138
|
+
let fence = null;
|
|
139
|
+
for (const line of text.split('\n')) {
|
|
140
|
+
const m = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
141
|
+
if (m) {
|
|
142
|
+
const ch = m[1][0];
|
|
143
|
+
if (!fence) fence = ch;
|
|
144
|
+
else if (fence === ch) fence = null;
|
|
145
|
+
visit(line, true);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
visit(line, fence !== null);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** H1 texts, ignoring `#` lines inside code fences. */
|
|
153
|
+
export function findH1s(body) {
|
|
154
|
+
const out = [];
|
|
155
|
+
walkOutsideFences(body, (line, inFence) => {
|
|
156
|
+
if (inFence) return;
|
|
157
|
+
const m = line.match(/^# (.+)$/);
|
|
158
|
+
if (m) out.push(m[1].trim());
|
|
159
|
+
});
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Collapse runs of 3+ blank lines to 2, outside fences only. */
|
|
164
|
+
function collapseBlankLines(text) {
|
|
165
|
+
const out = [];
|
|
166
|
+
let blanks = 0;
|
|
167
|
+
walkOutsideFences(text, (line, inFence) => {
|
|
168
|
+
if (!inFence && line.trim() === '') {
|
|
169
|
+
blanks += 1;
|
|
170
|
+
if (blanks > 2) return;
|
|
171
|
+
} else {
|
|
172
|
+
blanks = 0;
|
|
173
|
+
}
|
|
174
|
+
out.push(line);
|
|
175
|
+
});
|
|
176
|
+
return out.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A trailing `관련: [[a]] · [[b]]` line — the vault's existing footer convention. */
|
|
180
|
+
function isFooterLine(line) {
|
|
181
|
+
return /^관련:\s*\[\[/.test(line);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Obsidian's link target: everything before the first `#` (heading/block anchor) or `|`
|
|
188
|
+
* (display text). `[[a#S]]` and `[[a|A]]` carry the same graph edge as `[[a]]`.
|
|
189
|
+
*/
|
|
190
|
+
const linkTarget = (inner) => inner.split(/[#|]/)[0].trim();
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Body wikilinks that resolve to a page on disk, as `<basename>.md`.
|
|
194
|
+
*
|
|
195
|
+
* Read liberally, write strictly: every form Obsidian resolves is recognized here, so no
|
|
196
|
+
* edge is missed; renderFooter still emits bare form only. Fenced code is skipped —
|
|
197
|
+
* Obsidian renders `[[x]]` inside a fence literally and puts no edge in the graph, so
|
|
198
|
+
* absorbing it would mint an edge the vault does not have. Inner text that resolves to
|
|
199
|
+
* nothing on disk is dropped rather than slugified into a phantom (PM-4).
|
|
200
|
+
*/
|
|
201
|
+
function extractBodyLinks(body, basenames) {
|
|
202
|
+
const found = new Set();
|
|
203
|
+
walkOutsideFences(body, (line, inFence) => {
|
|
204
|
+
if (inFence) return;
|
|
205
|
+
for (const [, inner] of line.matchAll(WIKILINK)) {
|
|
206
|
+
const file = `${linkTarget(inner)}.md`;
|
|
207
|
+
if (basenames.has(file)) found.add(file);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return found;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The link targets the head's current footer already carries; empty when it has no footer. */
|
|
214
|
+
function existingFooterSlugs(head) {
|
|
215
|
+
const lines = head.split('\n');
|
|
216
|
+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
|
217
|
+
const last = lines.length ? lines[lines.length - 1] : '';
|
|
218
|
+
if (!isFooterLine(last)) return new Set();
|
|
219
|
+
return new Set([...last.matchAll(WIKILINK)].map(([, inner]) => linkTarget(inner)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Render the Related footer from the resolvable subset of links[] — bare [[slug]]
|
|
224
|
+
* form only (D8: OMC's extractWikiLinks slugifies any inner text, so a piped or
|
|
225
|
+
* heading form mints a phantom slug into links[]).
|
|
226
|
+
*
|
|
227
|
+
* BARE_SLUG makes D8 a test of the form itself rather than "whatever happens to name a
|
|
228
|
+
* file on disk". Nothing an OMC tree can hold is rejected by it (titleToSlug emits only
|
|
229
|
+
* this shape), and an entry it does reject keeps its OMC edge in links[] rather than being
|
|
230
|
+
* emitted in a form that mints a phantom on next ingest. Precisely: OMC's graph keeps that
|
|
231
|
+
* edge, Obsidian's loses it — `links: ["Weird Name.md"]` is a quoted YAML string, not a
|
|
232
|
+
* `[[…]]` Obsidian reads as an edge. Two graphs, and only one of them survives here.
|
|
233
|
+
*
|
|
234
|
+
* `present` (what the footer already shows) is kept whole however far past FOOTER_CAP it
|
|
235
|
+
* runs; the cap only limits what gets ADDED to it. Truncating instead would delete a
|
|
236
|
+
* resolvable bare [[link]] out of the body — a third way for a link to leave the footer,
|
|
237
|
+
* which "never delete a resolvable bare-form link" has no room for (its only two exceptions
|
|
238
|
+
* are a broken ref and the D8 rejection above, and both are enumerated). The two rules never
|
|
239
|
+
* actually compete: only a hand-written footer arrives over the cap, obsidizer's never does.
|
|
240
|
+
*
|
|
241
|
+
* Idempotent either way: run 2 reads run 1's output as `present`, which leaves no room
|
|
242
|
+
* under the cap, so it adds nothing and re-renders that same sorted set.
|
|
243
|
+
*/
|
|
244
|
+
function renderFooter(links, basenames, present = new Set()) {
|
|
245
|
+
const resolvable = [...new Set(
|
|
246
|
+
links.filter((l) => basenames.has(l))
|
|
247
|
+
.map((l) => l.replace(/\.md$/, ''))
|
|
248
|
+
.filter((slug) => BARE_SLUG.test(slug)),
|
|
249
|
+
)].sort(byCodeUnit);
|
|
250
|
+
const kept = resolvable.filter((slug) => present.has(slug));
|
|
251
|
+
const room = Math.max(0, FOOTER_CAP - kept.length);
|
|
252
|
+
const added = resolvable.filter((slug) => !present.has(slug)).slice(0, room);
|
|
253
|
+
const slugs = [...kept, ...added].sort(byCodeUnit);
|
|
254
|
+
return slugs.length ? FOOTER_PREFIX + slugs.map((s) => `[[${s}]]`).join(FOOTER_SEP) : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Replace/append the footer at the end of the head region (never inside a `## Update` section). */
|
|
258
|
+
function applyFooter(head, footer) {
|
|
259
|
+
const lines = head.split('\n');
|
|
260
|
+
const dropTrailingBlanks = () => { while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); };
|
|
261
|
+
dropTrailingBlanks();
|
|
262
|
+
if (lines.length && isFooterLine(lines[lines.length - 1])) {
|
|
263
|
+
lines.pop();
|
|
264
|
+
dropTrailingBlanks();
|
|
265
|
+
}
|
|
266
|
+
if (footer) lines.push('', footer);
|
|
267
|
+
return lines.join('\n');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** tags: strip a leading `#`, dedupe, keep authorial order (plan §3.6: "tags deduped + stable"). */
|
|
271
|
+
function normalizeTags(tags) {
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const t of tags) {
|
|
275
|
+
const tag = String(t).replace(/^#/, '').trim();
|
|
276
|
+
if (!tag || seen.has(tag)) continue;
|
|
277
|
+
seen.add(tag);
|
|
278
|
+
out.push(tag);
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A dir is OMC-managed when it has index.md AND log.md AND at least one
|
|
285
|
+
* non-reserved page carrying the full 9-key frontmatter signature.
|
|
286
|
+
*/
|
|
287
|
+
export function detectProfile(target) {
|
|
288
|
+
const dir = fs.statSync(target).isDirectory() ? target : path.dirname(target);
|
|
289
|
+
if (!fs.existsSync(path.join(dir, 'index.md')) || !fs.existsSync(path.join(dir, 'log.md'))) return 'generic';
|
|
290
|
+
for (const f of listMarkdown(dir)) {
|
|
291
|
+
if (RESERVED_FILES.has(f)) continue;
|
|
292
|
+
const fm = parseFrontmatter(read(path.join(dir, f)));
|
|
293
|
+
if (fm && FM_KEYS.every((k) => k in fm.map)) return 'omc';
|
|
294
|
+
}
|
|
295
|
+
return 'generic';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const read = (p) => fs.readFileSync(p, 'utf8');
|
|
299
|
+
|
|
300
|
+
/** Sorted explicitly — readdir order is not deterministic across filesystems. */
|
|
301
|
+
function listMarkdown(dir) {
|
|
302
|
+
return fs.readdirSync(dir)
|
|
303
|
+
.filter((f) => f.endsWith('.md') && !f.endsWith('.excalidraw.md'))
|
|
304
|
+
.sort(byCodeUnit);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Slug index + H1 index for the whole vault: alias uniqueness and footer resolvability need both. */
|
|
308
|
+
export function buildVaultModel(dir) {
|
|
309
|
+
const files = listMarkdown(dir);
|
|
310
|
+
const basenames = new Set(files);
|
|
311
|
+
const pages = new Map();
|
|
312
|
+
const h1Counts = new Map();
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
const raw = read(path.join(dir, file));
|
|
315
|
+
const fm = parseFrontmatter(raw);
|
|
316
|
+
const h1s = findH1s(fm ? fm.body : raw);
|
|
317
|
+
pages.set(file, { file, base: file.replace(/\.md$/, ''), raw, fm, h1s });
|
|
318
|
+
for (const h1 of h1s) h1Counts.set(h1, (h1Counts.get(h1) || 0) + 1);
|
|
319
|
+
}
|
|
320
|
+
return { dir, files, basenames, pages, h1Counts };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Which notes get `aliases: ["<H1>"]` — generic profile only, all six conditions.
|
|
325
|
+
* The value is the note's own H1, byte for byte; it is never invented.
|
|
326
|
+
*/
|
|
327
|
+
export function computeAliases(model, profile) {
|
|
328
|
+
const out = new Map();
|
|
329
|
+
if (profile !== 'generic') return out; // (a)
|
|
330
|
+
for (const page of model.pages.values()) {
|
|
331
|
+
if (!page.fm || page.h1s.length !== 1) continue; // (b)
|
|
332
|
+
const h1 = page.h1s[0];
|
|
333
|
+
if (norm(h1) === norm(page.base)) continue; // (c)
|
|
334
|
+
const others = [...model.pages.values()].filter((p) => p.file !== page.file);
|
|
335
|
+
if (others.some((p) => p.base === norm(h1))) continue; // (d)
|
|
336
|
+
if ((model.h1Counts.get(h1) || 0) > 1) continue; // (e)
|
|
337
|
+
if (page.fm.order.some((k) => k.toLowerCase() === 'aliases')) continue; // (f)
|
|
338
|
+
out.set(page.file, h1);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* markdown -> canonical markdown. Pure: no clock unless `now` is passed, and
|
|
345
|
+
* `now` is only consumed when the content actually changed (§3.6).
|
|
346
|
+
*/
|
|
347
|
+
export function canonicalizeText(raw, { profile, model, alias = null, now = null } = {}) {
|
|
348
|
+
const source = String(raw).replace(/\r\n/g, '\n');
|
|
349
|
+
const fm = parseFrontmatter(source);
|
|
350
|
+
if (!fm) return { text: source, changed: source !== String(raw), skipped: 'no-frontmatter' };
|
|
351
|
+
|
|
352
|
+
const basenames = model && model.basenames ? model.basenames : new Set();
|
|
353
|
+
let out;
|
|
354
|
+
|
|
355
|
+
if (profile === 'omc') {
|
|
356
|
+
const omc = parseOmcFrontmatter(fm.map);
|
|
357
|
+
if (!omc) return { text: source, changed: source !== String(raw), skipped: 'not-9-key' };
|
|
358
|
+
omc.tags = normalizeTags(omc.tags);
|
|
359
|
+
// Absorb the body's resolvable wikilinks into links[] BEFORE the footer is rebuilt.
|
|
360
|
+
// The footer is rendered from links[] alone, so without this a footer link that never
|
|
361
|
+
// entered links[] — a hand-added one; OMC-written footers always satisfy
|
|
362
|
+
// links[] ⊇ footer because OMC derives links[] via extractWikiLinks(content) — would
|
|
363
|
+
// be silently dropped and the graph edge lost. Same monotonic shape as OMC's own
|
|
364
|
+
// mergePage (`[...existing, ...extractWikiLinks(new)]`): links[] only ever grows, so
|
|
365
|
+
// this stays idempotent (run 2 finds them present ⇒ no-op) and invents nothing — the
|
|
366
|
+
// edge is already in the body, and only resolvable targets are absorbed.
|
|
367
|
+
omc.links = [...new Set([...omc.links, ...extractBodyLinks(fm.body, basenames)])].sort(byCodeUnit);
|
|
368
|
+
const footer = renderFooter(omc.links, basenames, existingFooterSlugs(splitUpdate(fm.body).head));
|
|
369
|
+
const body = canonicalizeBody(fm.body, footer);
|
|
370
|
+
out = `---\n${serializeFrontmatter(omc)}\n---\n${body}`;
|
|
371
|
+
if (now && out !== source) {
|
|
372
|
+
omc.updated = now;
|
|
373
|
+
out = `---\n${serializeFrontmatter(omc)}\n---\n${body}`;
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
// Generic: existing keys and their order are preserved verbatim; `aliases` is
|
|
377
|
+
// the only permitted addition, appended last, always double-quoted, FLOW form.
|
|
378
|
+
const lines = fm.lines.slice();
|
|
379
|
+
if (alias) lines.push(`aliases: ["${escapeYaml(alias)}"]`);
|
|
380
|
+
const body = canonicalizeBody(fm.body, null);
|
|
381
|
+
out = `---\n${lines.join('\n')}\n---\n${body}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { text: out, changed: out !== String(raw), skipped: null };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Split off the head: everything from a `## Update (<ts>)` heading on is preserve-only. */
|
|
388
|
+
function splitUpdate(body) {
|
|
389
|
+
const m = body.match(UPDATE_BOUNDARY);
|
|
390
|
+
return m ? { head: body.slice(0, m.index), tail: body.slice(m.index) } : { head: body, tail: '' };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Head region is canonicalized; a `## Update (<ts>)` section is copied through byte for byte. */
|
|
394
|
+
function canonicalizeBody(body, footer) {
|
|
395
|
+
const { head, tail } = splitUpdate(body);
|
|
396
|
+
let out = collapseBlankLines(head);
|
|
397
|
+
if (footer !== null) out = applyFooter(out, footer);
|
|
398
|
+
return `${out}${tail}`.replace(/\s+$/, '') + '\n';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Remove a temp file. A no-op once it has been renamed into place. */
|
|
402
|
+
function discardTemp(tmp) {
|
|
403
|
+
try { fs.unlinkSync(tmp); } catch { /* renamed away, or never created */ }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Write `text` into a fresh temp file beside `file` and fsync it; the caller renames it
|
|
408
|
+
* into place. Never a partial in-place write (PM-7).
|
|
409
|
+
*
|
|
410
|
+
* Mirrors the shape of OMC's atomicWriteFileSync (atomic-write.ts:166-206) — the bar the
|
|
411
|
+
* plan holds obsidizer's own writes to — reimplemented rather than borrowed (§6.2):
|
|
412
|
+
* randomUUID so two concurrent obsidizer processes can never collide on one temp path,
|
|
413
|
+
* `wx` so the open fails instead of truncating a temp file somebody else created, fsync
|
|
414
|
+
* so a rename cannot publish a short write (NFS).
|
|
415
|
+
*/
|
|
416
|
+
function writeTemp(file, text) {
|
|
417
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}${TMP_SUFFIX}.${crypto.randomUUID()}`);
|
|
418
|
+
// A failed `wx` open either found somebody else's file or created nothing, so there is
|
|
419
|
+
// deliberately no cleanup on this line — only past it do we own `tmp`.
|
|
420
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
421
|
+
try {
|
|
422
|
+
fs.writeFileSync(fd, text, 'utf8');
|
|
423
|
+
fs.fsyncSync(fd);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
fs.closeSync(fd);
|
|
426
|
+
discardTemp(tmp);
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
fs.closeSync(fd);
|
|
430
|
+
return tmp;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Stage the write, then read-back CAS, then rename (PM-7). `baseline` is the exact bytes
|
|
435
|
+
* the canonical text was computed from; if the file no longer matches, another writer
|
|
436
|
+
* (a concurrent wiki_ingest merging a `## Update` section) touched it since we read it,
|
|
437
|
+
* so writing would silently clobber that merge. Skip instead — the next run converges the
|
|
438
|
+
* page to identical bytes (one canonicalizer, one fixed point).
|
|
439
|
+
*
|
|
440
|
+
* The temp file is written BEFORE the CAS re-read on purpose: it moves the whole staging
|
|
441
|
+
* write out of the race window, leaving a single `rename` syscall between the check and
|
|
442
|
+
* the publish.
|
|
443
|
+
*
|
|
444
|
+
* That window is narrowed, NOT closed. A writer landing between the re-read and the
|
|
445
|
+
* rename is still clobbered. The residue is accepted rather than solved: closing it needs
|
|
446
|
+
* a lock the other writer honors, and depending on OMC's internal `.wiki-lock` is refused
|
|
447
|
+
* (§6.2 / ADR-c) — borrowing another plugin's private file breaks silently the day it is
|
|
448
|
+
* renamed. So this is a real reduction, not an elimination; do not read it as one.
|
|
449
|
+
*
|
|
450
|
+
* Returns false when the write was skipped.
|
|
451
|
+
*/
|
|
452
|
+
function writeIfUnchanged(file, baseline, text) {
|
|
453
|
+
const tmp = writeTemp(file, text);
|
|
454
|
+
try {
|
|
455
|
+
let current;
|
|
456
|
+
try {
|
|
457
|
+
current = fs.readFileSync(file, 'utf8');
|
|
458
|
+
} catch {
|
|
459
|
+
return false; // deleted mid-run; nothing of ours to preserve
|
|
460
|
+
}
|
|
461
|
+
if (current !== baseline) return false;
|
|
462
|
+
fs.renameSync(tmp, file);
|
|
463
|
+
return true;
|
|
464
|
+
} finally {
|
|
465
|
+
discardTemp(tmp); // a no-op once the rename above consumed it
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function run(target, { profile = 'auto', dryRun = false, now = null } = {}) {
|
|
470
|
+
if (!fs.existsSync(target)) throw new Error(`path not found: ${target}`);
|
|
471
|
+
const isFile = fs.statSync(target).isFile();
|
|
472
|
+
const dir = isFile ? path.dirname(target) : target;
|
|
473
|
+
const resolved = profile === 'auto' ? detectProfile(target) : profile;
|
|
474
|
+
const model = buildVaultModel(dir);
|
|
475
|
+
const aliases = computeAliases(model, resolved);
|
|
476
|
+
const stamp = now || new Date().toISOString();
|
|
477
|
+
|
|
478
|
+
const targets = isFile ? [path.basename(target)] : model.files;
|
|
479
|
+
const report = { profile: resolved, root: dir, dryRun, scanned: 0, changed: [], unchanged: 0, skipped: [], aliasesAdded: 0 };
|
|
480
|
+
|
|
481
|
+
for (const file of targets) {
|
|
482
|
+
const page = model.pages.get(file);
|
|
483
|
+
if (!page) throw new Error(`not a markdown file in ${dir}: ${file}`);
|
|
484
|
+
if (resolved === 'omc' && RESERVED_FILES.has(file)) {
|
|
485
|
+
report.skipped.push({ file, reason: 'reserved' });
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
report.scanned += 1;
|
|
489
|
+
// Opt-out: the marker anywhere in the file means hands off — no canonicalize, no
|
|
490
|
+
// write. The page still counts in the vault model (its slug resolves links, its H1
|
|
491
|
+
// still gates an alias elsewhere): "do not modify" is not "does not exist".
|
|
492
|
+
if (page.raw.includes(IGNORE_MARKER)) {
|
|
493
|
+
report.skipped.push({ file, reason: 'ignored' });
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const alias = aliases.get(file) || null;
|
|
497
|
+
const res = canonicalizeText(page.raw, { profile: resolved, model, alias, now: stamp });
|
|
498
|
+
if (res.skipped) {
|
|
499
|
+
report.skipped.push({ file, reason: res.skipped });
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (!res.changed) {
|
|
503
|
+
report.unchanged += 1;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (!dryRun && !writeIfUnchanged(path.join(dir, file), page.raw, res.text)) {
|
|
507
|
+
report.skipped.push({ file, reason: 'changed concurrently' });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
report.changed.push(file);
|
|
511
|
+
if (alias) report.aliasesAdded += 1;
|
|
512
|
+
}
|
|
513
|
+
return report;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function main(argv) {
|
|
517
|
+
const args = argv.filter((a) => !a.startsWith('--'));
|
|
518
|
+
const flag = (name) => argv.includes(`--${name}`);
|
|
519
|
+
const opt = (name, def) => {
|
|
520
|
+
const hit = argv.find((a) => a.startsWith(`--${name}=`));
|
|
521
|
+
return hit ? hit.slice(name.length + 3) : def;
|
|
522
|
+
};
|
|
523
|
+
if (args.length !== 1) {
|
|
524
|
+
console.error('usage: obsidize.mjs <path> [--profile=auto|omc|generic] [--dry-run] [--json]');
|
|
525
|
+
return 2;
|
|
526
|
+
}
|
|
527
|
+
const profile = opt('profile', 'auto');
|
|
528
|
+
if (!['auto', 'omc', 'generic'].includes(profile)) {
|
|
529
|
+
console.error(`unknown profile: ${profile}`);
|
|
530
|
+
return 2;
|
|
531
|
+
}
|
|
532
|
+
const report = run(path.resolve(args[0]), { profile, dryRun: flag('dry-run') });
|
|
533
|
+
if (flag('json')) {
|
|
534
|
+
console.log(JSON.stringify(report));
|
|
535
|
+
} else {
|
|
536
|
+
const tag = report.dryRun ? ' (dry-run)' : '';
|
|
537
|
+
console.log(`profile: ${report.profile}${tag}`);
|
|
538
|
+
console.log(`scanned: ${report.scanned} · changed: ${report.changed.length} · unchanged: ${report.unchanged} · aliases: ${report.aliasesAdded} · skipped: ${report.skipped.length}`);
|
|
539
|
+
for (const f of report.changed) console.log(` changed ${f}`);
|
|
540
|
+
for (const s of report.skipped) console.log(` skipped ${s.file} (${s.reason})`);
|
|
541
|
+
}
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// fileURLToPath, not URL.pathname: the latter yields "/C:/..." on Windows and never matches argv[1].
|
|
546
|
+
const invoked = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
547
|
+
if (invoked) {
|
|
548
|
+
try {
|
|
549
|
+
process.exit(main(process.argv.slice(2)));
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error(`obsidize: ${err.message}`);
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: setup-java
|
|
3
|
+
description: "(banker) JDK 21 + JAVA_HOME 설치(alternatives로 java·javac 비대칭 방지, Debian은 Adoptium 전용). jdtls 등 Java LSP 전제. 'setup-java'/'JDK 설치'/'Java 21 설치' 또는 /banker:setup 시 사용."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# setup-java: JDK 21 + JAVA_HOME 설치
|
|
7
|
+
|
|
8
|
+
Java LSP(`jdtls`) 등 Java 도구가 요구하는 JDK 21과 `JAVA_HOME`을 설치·구성한다.
|
|
9
|
+
멱등(이미 21이면 skip·검증만). 답변은 한글(명령·경로는 영문).
|
|
10
|
+
|
|
11
|
+
## 런타임 배선
|
|
12
|
+
- **Claude Code**: `docs-setup`의 PlantUML 렌더(`java -jar plantuml.jar`)가 java 존재를 전제로 동작.
|
|
13
|
+
- **Codex/OMX**: `jdtls`(Java LSP)와 Spring Boot Language Server(`java -jar ...`)가 JDK 21을 요구한다. 이를 실제로 내려받아 구동하는 별도 LSP 설치 스킬(예: setup-lsp)의 전제조건이 이 스킬이다.
|
|
14
|
+
- 이 스킬은 JDK 설치·`JAVA_HOME` 확정까지만 담당하고, jdtls 자체 배포·`JDTLS_JVM_ARGS` 같은 힙 튜닝은 LSP 설치 스킬의 영역이다.
|
|
15
|
+
|
|
16
|
+
## 0. 감지 (먼저, 추정 금지)
|
|
17
|
+
```bash
|
|
18
|
+
[ -f /etc/os-release ] && . /etc/os-release && echo "distro=$ID ${VERSION_ID}"
|
|
19
|
+
command -v java >/dev/null && java -version 2>&1 | head -1 || echo "java=미설치"
|
|
20
|
+
command -v javac >/dev/null && javac -version 2>&1 || echo "javac=미설치"
|
|
21
|
+
echo "JAVA_HOME=${JAVA_HOME:-미설정}"
|
|
22
|
+
```
|
|
23
|
+
```powershell
|
|
24
|
+
$PSVersionTable.PSVersion
|
|
25
|
+
if (Get-Command java -ErrorAction SilentlyContinue) { java -version } else { "java: 미설치" }
|
|
26
|
+
[Environment]::GetEnvironmentVariable('JAVA_HOME','User')
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 1. Rocky Linux 8
|
|
30
|
+
```bash
|
|
31
|
+
sudo dnf install -y java-21-openjdk-devel
|
|
32
|
+
JAVA21_HOME=$(dirname "$(dirname "$(readlink -f /usr/lib/jvm/java-21-openjdk*/bin/java)")")
|
|
33
|
+
sudo alternatives --install /usr/bin/java java "$JAVA21_HOME/bin/java" 21 \
|
|
34
|
+
--slave /usr/bin/javac javac "$JAVA21_HOME/bin/javac" \
|
|
35
|
+
--slave /usr/bin/jar jar "$JAVA21_HOME/bin/jar"
|
|
36
|
+
sudo alternatives --set java "$JAVA21_HOME/bin/java"
|
|
37
|
+
export JAVA_HOME="$JAVA21_HOME" # 아래 "rc 마커 공통"이 참조
|
|
38
|
+
```
|
|
39
|
+
- `--slave`로 `java`·`javac`·`jar`를 한 그룹으로 묶는 이유: 나중에 `alternatives --config java`로 버전을 바꿀 때 `javac`가 따로 놀면(비대칭) 컴파일이 엉뚱한 버전으로 실행된다.
|
|
40
|
+
|
|
41
|
+
## 2. Ubuntu 20.04~24.04
|
|
42
|
+
```bash
|
|
43
|
+
sudo apt update
|
|
44
|
+
sudo apt install -y openjdk-21-jdk || echo "openjdk-21-jdk 미제공. 아래 3. Debian의 Adoptium 블록을 그대로 적용"
|
|
45
|
+
sudo update-alternatives --config java # 여러 버전 중 21 선택(javac도 slave로 동반 전환)
|
|
46
|
+
javac -version # java와 버전 일치 확인(비대칭 점검)
|
|
47
|
+
export JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" # 아래 "rc 마커 공통"이 참조
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 3. Debian 12 (bookworm): Adoptium Temurin 전용
|
|
51
|
+
⚠️ **default·backports 저장소 모두 JDK 21을 제공하지 않는다.** Adoptium 레포 추가가 유일한 경로(재확인: 배포판이 이후 21을 backport하면 이 단계는 생략 가능).
|
|
52
|
+
```bash
|
|
53
|
+
sudo apt-get install -y wget gnupg
|
|
54
|
+
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | \
|
|
55
|
+
sudo gpg --dearmor -o /usr/share/keyrings/adoptium.gpg
|
|
56
|
+
echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" | \
|
|
57
|
+
sudo tee /etc/apt/sources.list.d/adoptium.list
|
|
58
|
+
sudo apt-get update
|
|
59
|
+
sudo apt-get install -y temurin-21-jdk
|
|
60
|
+
sudo update-alternatives --config java
|
|
61
|
+
export JAVA_HOME="/usr/lib/jvm/temurin-21-jdk-$(dpkg --print-architecture)" # amd64/arm64 자동
|
|
62
|
+
```
|
|
63
|
+
(Ubuntu에서 `openjdk-21-jdk`가 없을 때도 이 블록을 그대로 재사용한다.)
|
|
64
|
+
|
|
65
|
+
## 4. macOS (Apple Silicon / Intel)
|
|
66
|
+
```zsh
|
|
67
|
+
brew install openjdk@21
|
|
68
|
+
sudo ln -sfn "$(brew --prefix openjdk@21)/libexec/openjdk.jdk" \
|
|
69
|
+
/Library/Java/JavaVirtualMachines/openjdk-21.jdk # 유일한 sudo, 1회성
|
|
70
|
+
export JAVA_HOME="$(brew --prefix openjdk@21)" # 아래 공통 rc 블록 참고
|
|
71
|
+
/usr/libexec/java_home -v 21
|
|
72
|
+
```
|
|
73
|
+
Apple Silicon(`/opt/homebrew`)·Intel(`/usr/local`) 차이는 `brew --prefix`가 자동 흡수하므로 이 스크립트는 두 아키텍처에 동일 적용.
|
|
74
|
+
|
|
75
|
+
## 5. Windows 10/11
|
|
76
|
+
```powershell
|
|
77
|
+
winget install --id Microsoft.OpenJDK.21 -e
|
|
78
|
+
$jdk = (Get-ChildItem "$env:ProgramFiles\Microsoft\jdk-21*" -Directory | Select-Object -First 1).FullName
|
|
79
|
+
[Environment]::SetEnvironmentVariable('JAVA_HOME', $jdk, 'User')
|
|
80
|
+
[Environment]::SetEnvironmentVariable('PATH', "$jdk\bin;" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')
|
|
81
|
+
```
|
|
82
|
+
User-scope로 지정하면 Machine-scope에 남아있을 수 있는 구버전(Java 8 등)보다 PATH 우선순위가 앞선다. 새 터미널에서 반영 확인.
|
|
83
|
+
|
|
84
|
+
## rc 마커 공통 (Linux/macOS, Windows 제외)
|
|
85
|
+
```bash
|
|
86
|
+
RC_FILE="$HOME/.bashrc" # macOS는 $HOME/.zshrc
|
|
87
|
+
grep -q '### BANKER_JAVA_BEGIN ###' "$RC_FILE" || cat >> "$RC_FILE" <<EOF
|
|
88
|
+
|
|
89
|
+
### BANKER_JAVA_BEGIN ###
|
|
90
|
+
export JAVA_HOME="$JAVA_HOME"
|
|
91
|
+
export PATH="\$JAVA_HOME/bin:\$PATH"
|
|
92
|
+
### BANKER_JAVA_END ###
|
|
93
|
+
EOF
|
|
94
|
+
source "$RC_FILE"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## 검증 (보고 의무)
|
|
98
|
+
```bash
|
|
99
|
+
java -version # openjdk 21.x 기대
|
|
100
|
+
javac -version # java와 동일 21.x(다르면 alternatives 비대칭)
|
|
101
|
+
echo "$JAVA_HOME"
|
|
102
|
+
```
|
|
103
|
+
4-field 보고: 변경(설치 패키지·alternatives 전환·JAVA_HOME 값) / Evidence(위 출력) / 검증(java·javac 버전 일치) / Unknown(관리자 권한 부재로 alternatives 미실행 등, 가짜 성공 금지).
|
|
104
|
+
|
|
105
|
+
## 함정
|
|
106
|
+
- **java/javac 비대칭**: `alternatives`(RHEL 계열)·`update-alternatives`(Debian 계열)에서 `java`만 바꾸고 `javac`를 안 바꾸면 "실행은 21, 컴파일은 8" 같은 사고 발생 → `--slave`로 묶거나 전환 직후 `javac -version` 대조.
|
|
107
|
+
- **Debian 12 저장소 공백**: default·backports 모두 21 없음 → Adoptium 필수(버전 상황은 실행 전 재확인).
|
|
108
|
+
- **Adoptium GPG/네트워크 차단**: 사내 프록시 환경이면 `packages.adoptium.net` 접근이 막힐 수 있음 → 정직 보고, 프록시 예외 요청.
|
|
109
|
+
- **macOS symlink**: 1회성 sudo. `brew upgrade`로 21 내 패치 버전이 올라가도 재실행 불요하나, 메이저가 바뀌면(예: 21→25) 재실행 필요.
|
|
110
|
+
- **Windows JAVA_HOME 누락**: 설정 안 하면 jdtls 등이 엉뚱한(또는 없는) JRE를 찾아 조용히 실패 → User-scope로 명시 설정 후 새 터미널에서 확인.
|
|
111
|
+
- **버전 기준 재확인**: JDK 21 고정은 jdtls 호환 기준(2026-07 시점). 이후 jdtls·Spring Boot LS가 더 최신 LTS(예: 25)를 요구하게 되면 이 스킬의 버전 인자를 올려 재실행.
|
|
112
|
+
|
|
113
|
+
ARGUMENTS: [--version <n>] (없으면 21 자동 설치)
|