wyvrnpm 2.9.0 → 2.10.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 CHANGED
@@ -143,6 +143,62 @@ wyvrnpm init --root ./my-project
143
143
 
144
144
  ---
145
145
 
146
+ ### `wyvrnpm bootstrap`
147
+
148
+ Scaffolds a first-draft `wyvrn.json` for an open-source C++ library so it can be packaged into your wyvrnpm registry with a single review pass instead of hand-writing a manifest from scratch. Clones the upstream git repo, detects `name` / `version` / `kind` from the checkout, applies known-library CMake flag hints (skip tests, skip examples, flip install gates), and prints a TODO report for everything a machine can't decide for you.
149
+
150
+ Designed for batch-converting a list of forked upstreams (fmt, spdlog, zlib, Catch2, etc.) into your private registry.
151
+
152
+ ```bash
153
+ # Typical single-repo conversion
154
+ wyvrnpm bootstrap https://github.com/fmtlib/fmt.git --dest ./pkgs/fmt
155
+ cd ./pkgs/fmt # review wyvrn.json, settle TODOs
156
+ wyvrnpm install
157
+ wyvrnpm build --install --all-configs
158
+ wyvrnpm publish --source team-s3
159
+
160
+ # Override the derived name / version / kind
161
+ wyvrnpm bootstrap https://github.com/nlohmann/json.git \
162
+ --dest ./pkgs/nlohmann-json \
163
+ --name nlohmann-json \
164
+ --kind HeaderOnlyLib
165
+
166
+ # Work on a directory you already cloned
167
+ wyvrnpm bootstrap --no-clone --dest ./pkgs/fmt --force
168
+
169
+ # Preview without writing — TODO report only
170
+ wyvrnpm bootstrap https://github.com/google/re2.git --dest /tmp/re2 --dry-run
171
+
172
+ # Batch driver — one shell loop, no wyvrnpm subcommand needed
173
+ while read url; do
174
+ wyvrnpm bootstrap "$url" --dest "./pkgs/$(basename "$url" .git)" --force
175
+ done < urls.txt
176
+ # then review, build, and publish each in a loop of your own
177
+ ```
178
+
179
+ **Options**
180
+
181
+ | Option | Default | Description |
182
+ |---|---|---|
183
+ | `<url>` positional | *(required)* | Upstream git URL. Accepts HTTPS or SSH form. |
184
+ | `--dest <dir>` | `basename(url)` | Destination directory for the clone. |
185
+ | `--ref <ref>` | *(remote HEAD)* | Clone at a specific branch / tag / SHA. |
186
+ | `--name` | *(from URL)* | Override the derived package name. |
187
+ | `--pkg-version` | *(nearest git tag)* | Override the version. Named oddly so yargs doesn't confuse it with the built-in `--version`. |
188
+ | `--kind` | *(auto-detect)* | Force `ConsoleApp` / `StaticLib` / `DynamicLib` / `HeaderOnlyLib` / etc. |
189
+ | `--description` | *(TODO placeholder)* | One-line description written into `wyvrn.json`. |
190
+ | `--no-clone` | `false` | Skip the clone step; operate on an already-populated `--dest`. |
191
+ | `--shallow` | `false` | `--depth 1` clone. Faster, but git tag detection degrades — pair with `--pkg-version` when using this. |
192
+ | `--dry-run` | `false` | Print the proposed manifest + TODOs; don't write `wyvrn.json`. |
193
+ | `--force` | `false` | Overwrite an existing destination and/or `wyvrn.json`. |
194
+ | `--format` | `text` | Output format: `text` (human) or `json` (CI — one object on stdout). |
195
+
196
+ The cookbook of per-library hints lives in [src/bootstrap/cookbook.js](src/bootstrap/cookbook.js). Current coverage is ~30 popular libraries (fmt, spdlog, nlohmann/json, yaml-cpp, zlib, Catch2, lz4, brotli, hiredis, redis-plus-plus, GLM, SQLiteCpp, re2, …). Adding a new entry is a two-line change — submit a PR.
197
+
198
+ The patterns bootstrap detects (header-only with INTERFACE target, standard CMake static lib, CMake with wyvrnpm dep, ABI-option'd lib, non-CMake upstream) are catalogued in the bundled Agent Skill at [claude/skills/wyvrnpm/references/oss-conversion-patterns.md](claude/skills/wyvrnpm/references/oss-conversion-patterns.md) — Claude reads it when you ask "how do I convert X to a wyvrnpm package?".
199
+
200
+ ---
201
+
146
202
  ### `wyvrnpm add`
147
203
 
148
204
  Adds one or more dependencies to `wyvrn.json` without downloading them. Useful when you know the package name but not the latest version.
package/bin/wyvrn.js CHANGED
@@ -14,6 +14,7 @@ const build = require('../src/commands/build');
14
14
  const show = require('../src/commands/show');
15
15
  const installSkill = require('../src/commands/install-skill');
16
16
  const cache = require('../src/commands/cache');
17
+ const bootstrap = require('../src/commands/bootstrap');
17
18
  const { link, unlink } = require('../src/commands/link');
18
19
 
19
20
  yargs
@@ -37,6 +38,83 @@ yargs
37
38
  (argv) => init(argv),
38
39
  )
39
40
 
41
+ // ── bootstrap ─────────────────────────────────────────────────────────────
42
+ .command(
43
+ 'bootstrap <url> [dest]',
44
+ 'Scaffold a first-draft wyvrn.json for an OSS C++ library. Shallow-clones ' +
45
+ '(or reuses with --no-clone), detects name/version/kind, applies known-library ' +
46
+ 'cookbook hints, and prints TODOs for gotchas worth a human look before publish. ' +
47
+ 'Designed for batch-converting upstream repos to wyvrnpm packages.',
48
+ (y) => {
49
+ y
50
+ .positional('url', {
51
+ type: 'string',
52
+ describe: 'Upstream git URL (e.g. https://github.com/fmtlib/fmt.git)',
53
+ })
54
+ .positional('dest', {
55
+ type: 'string',
56
+ describe: 'Destination directory (default: basename(url))',
57
+ })
58
+ .option('dest', {
59
+ type: 'string',
60
+ description: 'Destination directory (alias for the positional).',
61
+ })
62
+ .option('ref', {
63
+ type: 'string',
64
+ description: 'Clone at this branch/tag/SHA (default: the remote HEAD).',
65
+ })
66
+ .option('name', {
67
+ type: 'string',
68
+ description: 'Override the derived package name.',
69
+ })
70
+ .option('pkg-version', {
71
+ type: 'string',
72
+ description: 'Override the wyvrnpm package version (otherwise: nearest git tag padded to 4 parts). Not named --version to avoid colliding with yargs\'s built-in --version flag.',
73
+ })
74
+ .option('shallow', {
75
+ type: 'boolean',
76
+ default: false,
77
+ description: 'Use a shallow (`--depth 1`) clone. Faster, but git tag detection degrades to a TODO — pass --pkg-version when combining with this.',
78
+ })
79
+ .option('kind', {
80
+ type: 'string',
81
+ choices: ['ConsoleApp', 'StaticLib', 'DynamicLib', 'HeaderOnlyLib', 'WebApp', 'Utility', 'Service', 'TestProject'],
82
+ description: 'Override the detected kind.',
83
+ })
84
+ .option('description', {
85
+ type: 'string',
86
+ description: 'One-line description written into the manifest.',
87
+ })
88
+ .option('no-clone', {
89
+ type: 'boolean',
90
+ default: false,
91
+ description: 'Skip cloning — operate on an existing directory at --dest.',
92
+ })
93
+ .option('dry-run', {
94
+ type: 'boolean',
95
+ default: false,
96
+ description: 'Print the proposed manifest + TODO report, do not write wyvrn.json.',
97
+ })
98
+ .option('force', {
99
+ type: 'boolean',
100
+ default: false,
101
+ description: 'Overwrite an existing destination or existing wyvrn.json.',
102
+ })
103
+ .option('format', {
104
+ type: 'string',
105
+ choices: ['text', 'json'],
106
+ default: 'text',
107
+ description: 'Output format for CI pipelines. `json` emits the manifest + TODOs as one JSON object on stdout.',
108
+ });
109
+ },
110
+ (argv) => bootstrap({
111
+ ...argv,
112
+ version: argv['pkg-version'],
113
+ noClone: argv['no-clone'],
114
+ dryRun: argv['dry-run'],
115
+ }),
116
+ )
117
+
40
118
  // ── add ───────────────────────────────────────────────────────────────────
41
119
  .command(
42
120
  'add <packages...>',
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wyvrnpm",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "A simple, static-hosting-compatible C++ package manager",
5
5
  "keywords": [
6
6
  "c++",
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Known-library cookbook for `wyvrnpm bootstrap`.
5
+ *
6
+ * Each entry captures the non-obvious tweaks a wyvrnpm recipe needs for a
7
+ * popular upstream — the CMake cache variables that disable tests /
8
+ * examples / docs so the install tree doesn't balloon, install gates the
9
+ * upstream hides behind a flag (the `ZLIB_INSTALL=ON` class of gotcha,
10
+ * see SKILL.md references/manifest-authoring.md §build), and any
11
+ * transitive wyvrnpm deps the upstream already depends on.
12
+ *
13
+ * Keyed by a case-insensitive match on the repo tail. Matching is
14
+ * restricted to exact tails so `wyvrnpm bootstrap` never surprises a
15
+ * user whose fork name collides coincidentally — unmatched tails get
16
+ * the safe-default recipe.
17
+ *
18
+ * Keep this file short and dumb. Rich per-library behaviour belongs in
19
+ * the author's actual `wyvrn.json` — the cookbook is just the "everyone
20
+ * hits these flags on day one" cheat sheet.
21
+ *
22
+ * @typedef {Object} CookbookEntry
23
+ * @property {'HeaderOnlyLib'|'StaticLib'|'DynamicLib'} [kind]
24
+ * Override `detect.js` when upstream's CMakeLists lies (e.g. fmt has
25
+ * both a STATIC lib and an INTERFACE `fmt-header-only`).
26
+ * @property {string[]} [configure]
27
+ * `-DKEY=VAL` args threaded into build.configure. Preserves order so a
28
+ * `-DBUILD_SHARED_LIBS=OFF` comes before a `-DXYZ_OPTION=...` override.
29
+ * @property {Record<string,string>} [dependencies]
30
+ * Known wyvrnpm deps — merged into manifest.dependencies. Versions
31
+ * use `"latest"` so the user picks up whatever the team's registry
32
+ * publishes; they can pin in review.
33
+ * @property {string[]} [notes]
34
+ * Human-readable reminders appended to the TODO report.
35
+ */
36
+
37
+ /** @type {Record<string, CookbookEntry>} */
38
+ const KNOWN_LIBRARIES = Object.freeze({
39
+ // ── Formatting / logging ─────────────────────────────────────────────
40
+ fmt: {
41
+ kind: 'StaticLib',
42
+ configure: ['-DFMT_TEST=OFF', '-DFMT_DOC=OFF'],
43
+ notes: [
44
+ 'fmt also ships a `fmt-header-only` INTERFACE target. If you want ' +
45
+ 'the header-only variant, declare kind=HeaderOnlyLib and add -DFMT_INSTALL=ON.',
46
+ ],
47
+ },
48
+ spdlog: {
49
+ kind: 'StaticLib',
50
+ configure: [
51
+ '-DSPDLOG_BUILD_TESTS=OFF',
52
+ '-DSPDLOG_BUILD_EXAMPLE=OFF',
53
+ '-DSPDLOG_INSTALL=ON',
54
+ '-DSPDLOG_FMT_EXTERNAL=ON',
55
+ ],
56
+ dependencies: { fmt: 'latest' },
57
+ notes: [
58
+ 'SPDLOG_FMT_EXTERNAL=ON makes spdlog link against the wyvrnpm fmt package ' +
59
+ 'instead of its bundled fork. Drop if you want the bundled copy.',
60
+ ],
61
+ },
62
+
63
+ // ── JSON / config / serialization ────────────────────────────────────
64
+ json: { kind: 'HeaderOnlyLib', configure: ['-DJSON_BuildTests=OFF'] },
65
+ rapidjson: { kind: 'HeaderOnlyLib', configure: ['-DRAPIDJSON_BUILD_TESTS=OFF', '-DRAPIDJSON_BUILD_EXAMPLES=OFF', '-DRAPIDJSON_BUILD_DOC=OFF'] },
66
+ jsoncpp: { kind: 'StaticLib', configure: ['-DJSONCPP_WITH_TESTS=OFF', '-DJSONCPP_WITH_POST_BUILD_UNITTEST=OFF'] },
67
+ 'yaml-cpp':{ kind: 'StaticLib', configure: ['-DYAML_CPP_BUILD_TESTS=OFF', '-DYAML_CPP_BUILD_TOOLS=OFF', '-DYAML_CPP_INSTALL=ON'] },
68
+ pugixml: { kind: 'StaticLib', configure: ['-DPUGIXML_BUILD_TESTS=OFF'] },
69
+ tinyxml2: { kind: 'StaticLib', configure: ['-Dtinyxml2_BUILD_TESTING=OFF'] },
70
+ cereal: { kind: 'HeaderOnlyLib', configure: ['-DJUST_INSTALL_CEREAL=ON'] },
71
+ 'msgpack-c': { kind: 'StaticLib', configure: ['-DMSGPACK_BUILD_TESTS=OFF', '-DMSGPACK_BUILD_EXAMPLES=OFF'] },
72
+
73
+ // ── CLI / utilities ──────────────────────────────────────────────────
74
+ cli11: { kind: 'HeaderOnlyLib', configure: ['-DCLI11_BUILD_TESTS=OFF', '-DCLI11_BUILD_EXAMPLES=OFF'] },
75
+ cxxopts: { kind: 'HeaderOnlyLib', configure: ['-DCXXOPTS_BUILD_TESTS=OFF', '-DCXXOPTS_BUILD_EXAMPLES=OFF'] },
76
+ magic_enum: { kind: 'HeaderOnlyLib', configure: ['-DMAGIC_ENUM_OPT_BUILD_EXAMPLES=OFF', '-DMAGIC_ENUM_OPT_BUILD_TESTS=OFF'] },
77
+ date: { kind: 'StaticLib', configure: ['-DBUILD_TZ_LIB=ON', '-DUSE_SYSTEM_TZ_DB=OFF'] },
78
+ utfcpp: { kind: 'HeaderOnlyLib', configure: ['-DUTF8_TESTS=OFF', '-DUTF8_SAMPLES=OFF'] },
79
+
80
+ // ── Testing / benchmarking ──────────────────────────────────────────
81
+ catch2: { kind: 'StaticLib', configure: ['-DCATCH_INSTALL_DOCS=OFF', '-DCATCH_BUILD_TESTING=OFF'] },
82
+ doctest: { kind: 'HeaderOnlyLib', configure: ['-DDOCTEST_WITH_TESTS=OFF', '-DDOCTEST_WITH_MAIN_IN_STATIC_LIB=OFF'] },
83
+ benchmark: {
84
+ kind: 'StaticLib',
85
+ configure: ['-DBENCHMARK_ENABLE_TESTING=OFF', '-DBENCHMARK_ENABLE_GTEST_TESTS=OFF'],
86
+ notes: ['google/benchmark — only one of several projects named "benchmark"; confirm the repo URL.'],
87
+ },
88
+
89
+ // ── Compression ──────────────────────────────────────────────────────
90
+ zlib: {
91
+ kind: 'StaticLib',
92
+ configure: ['-DZLIB_BUILD_EXAMPLES=OFF'],
93
+ notes: [
94
+ 'zlib is the textbook example for an `options.shared` toggle — see ' +
95
+ 'references/worked-example-zlib.md. Consider declaring it before you publish.',
96
+ ],
97
+ },
98
+ lz4: { kind: 'StaticLib', configure: ['-DLZ4_BUILD_LEGACY_LZ4C=OFF', '-DLZ4_BUILD_CLI=OFF', '-DBUILD_SHARED_LIBS=OFF'] },
99
+ snappy: { kind: 'StaticLib', configure: ['-DSNAPPY_BUILD_TESTS=OFF', '-DSNAPPY_BUILD_BENCHMARKS=OFF'] },
100
+ brotli: { kind: 'StaticLib', configure: ['-DBROTLI_DISABLE_TESTS=ON'] },
101
+ bzip2: {
102
+ notes: [
103
+ 'bzip2 uses autotools (no CMakeLists.txt). Either ship a wrapper CMakeLists.txt ' +
104
+ 'in your fork or prebuild + publish as a binary-only artefact.',
105
+ ],
106
+ },
107
+
108
+ // ── Networking / HTTP ────────────────────────────────────────────────
109
+ 'cpp-httplib': { kind: 'HeaderOnlyLib', configure: ['-DHTTPLIB_TEST=OFF'] },
110
+ cpr: {
111
+ kind: 'StaticLib',
112
+ configure: ['-DCPR_BUILD_TESTS=OFF', '-DCPR_USE_SYSTEM_CURL=ON'],
113
+ notes: ['cpr needs libcurl — add a wyvrnpm `libcurl` dep if your registry publishes one, or rely on system curl.'],
114
+ },
115
+ websocketpp: {
116
+ kind: 'HeaderOnlyLib',
117
+ notes: ['websocketpp requires boost::asio or standalone Asio — add the appropriate dep.'],
118
+ },
119
+ cppzmq: {
120
+ kind: 'HeaderOnlyLib',
121
+ notes: ['cppzmq needs libzmq — add a wyvrnpm dep for libzmq before publish.'],
122
+ },
123
+ hiredis: { kind: 'StaticLib', configure: ['-DDISABLE_TESTS=ON', '-DENABLE_EXAMPLES=OFF'] },
124
+ 'redis-plus-plus': {
125
+ kind: 'StaticLib',
126
+ configure: ['-DREDIS_PLUS_PLUS_BUILD_TEST=OFF', '-DREDIS_PLUS_PLUS_USE_TLS=OFF'],
127
+ dependencies: { hiredis: 'latest' },
128
+ },
129
+
130
+ // ── Concurrency ──────────────────────────────────────────────────────
131
+ concurrentqueue: { kind: 'HeaderOnlyLib' },
132
+ readerwriterqueue: { kind: 'HeaderOnlyLib' },
133
+
134
+ // ── Graphics / image ─────────────────────────────────────────────────
135
+ stb: {
136
+ kind: 'HeaderOnlyLib',
137
+ notes: [
138
+ 'stb is a header drop — no CMakeLists.txt upstream. Ship a small wrapper ' +
139
+ 'CMakeLists.txt in your fork that declares an INTERFACE target with ' +
140
+ 'target_include_directories(... INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) and install() rules.',
141
+ ],
142
+ },
143
+ glm: { kind: 'HeaderOnlyLib', configure: ['-DGLM_TEST_ENABLE=OFF', '-DGLM_BUILD_LIBRARY=OFF'] },
144
+ imgui: {
145
+ notes: [
146
+ 'imgui ships no CMakeLists.txt. Ship a wrapper CMakeLists.txt in your fork ' +
147
+ 'that picks the backends you need (OpenGL3/Win32/DX11/etc) and declares a ' +
148
+ 'StaticLib with them.',
149
+ ],
150
+ },
151
+ nuklear: {
152
+ kind: 'HeaderOnlyLib',
153
+ notes: [
154
+ 'Nuklear is a single-header drop. Same pattern as stb: ship an INTERFACE ' +
155
+ 'wrapper CMakeLists.txt in your fork.',
156
+ ],
157
+ },
158
+
159
+ // ── Database ─────────────────────────────────────────────────────────
160
+ sqlitecpp: { kind: 'StaticLib', configure: ['-DSQLITECPP_RUN_CPPLINT=OFF', '-DSQLITECPP_RUN_CPPCHECK=OFF', '-DSQLITECPP_BUILD_TESTS=OFF', '-DSQLITECPP_BUILD_EXAMPLES=OFF'] },
161
+
162
+ // ── Crypto ───────────────────────────────────────────────────────────
163
+ libsodium: {
164
+ notes: [
165
+ 'libsodium uses autotools (configure.ac, Makefile.am). wyvrnpm source-build ' +
166
+ 'supports cmake only — ship a wrapper CMakeLists.txt in your fork, or ' +
167
+ 'prebuild + publish as a binary-only artefact.',
168
+ ],
169
+ },
170
+
171
+ // ── Text / regex ─────────────────────────────────────────────────────
172
+ re2: { kind: 'StaticLib', configure: ['-DRE2_BUILD_TESTING=OFF'] },
173
+ });
174
+
175
+ /**
176
+ * Look up cookbook hints for a given package tail (e.g. "fmt", "zlib").
177
+ * Case-insensitive; returns a defensive copy so callers can merge freely.
178
+ *
179
+ * @param {string} key
180
+ * @returns {CookbookEntry | null}
181
+ */
182
+ function lookupCookbook(key) {
183
+ if (typeof key !== 'string') return null;
184
+ const normal = key.trim().toLowerCase();
185
+ if (!normal) return null;
186
+ const entry = KNOWN_LIBRARIES[normal];
187
+ if (!entry) return null;
188
+ return {
189
+ ...entry,
190
+ configure: entry.configure ? [...entry.configure] : undefined,
191
+ dependencies: entry.dependencies ? { ...entry.dependencies } : undefined,
192
+ notes: entry.notes ? [...entry.notes] : undefined,
193
+ };
194
+ }
195
+
196
+ module.exports = { KNOWN_LIBRARIES, lookupCookbook };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Inspect a cloned repo directory and infer the wyvrnpm `kind`, plus
8
+ * anything else a bootstrap flow can read off the filesystem cheaply.
9
+ *
10
+ * We explicitly do NOT fully parse CMakeLists.txt — a regex scan is good
11
+ * enough for the 80% case, and the bootstrap command's job is to produce
12
+ * a *first draft* that the human reviews before publish. When the signal
13
+ * is ambiguous we return `unknown` and let the TODO report surface it.
14
+ *
15
+ * @typedef {Object} DetectResult
16
+ * @property {'HeaderOnlyLib' | 'StaticLib' | 'DynamicLib' | 'unknown'} kind
17
+ * @property {boolean} hasCMakeLists — root CMakeLists.txt present?
18
+ * @property {boolean} hasConfigureAc — autotools (libsodium, bzip2)?
19
+ * @property {boolean} hasMeson — meson.build present?
20
+ * @property {string[]} notes — human-readable signals for the TODO report.
21
+ */
22
+
23
+ /**
24
+ * @param {string} repoDir
25
+ * @returns {DetectResult}
26
+ */
27
+ function detectFromRepo(repoDir) {
28
+ const notes = [];
29
+ const result = {
30
+ kind: 'unknown',
31
+ hasCMakeLists: false,
32
+ hasConfigureAc: false,
33
+ hasMeson: false,
34
+ notes,
35
+ };
36
+
37
+ if (!fs.existsSync(repoDir) || !fs.statSync(repoDir).isDirectory()) {
38
+ notes.push(`directory does not exist: ${repoDir}`);
39
+ return result;
40
+ }
41
+
42
+ const cmakePath = path.join(repoDir, 'CMakeLists.txt');
43
+ result.hasCMakeLists = fs.existsSync(cmakePath);
44
+ result.hasConfigureAc = fs.existsSync(path.join(repoDir, 'configure.ac'))
45
+ || fs.existsSync(path.join(repoDir, 'configure'));
46
+ result.hasMeson = fs.existsSync(path.join(repoDir, 'meson.build'));
47
+
48
+ if (!result.hasCMakeLists) {
49
+ if (result.hasConfigureAc) {
50
+ notes.push(
51
+ 'no CMakeLists.txt — upstream uses autotools. wyvrnpm source-build ' +
52
+ 'supports cmake only; ship a wrapper CMakeLists.txt in your fork or ' +
53
+ 'prebuild and publish as a binary-only artefact.',
54
+ );
55
+ } else if (result.hasMeson) {
56
+ notes.push(
57
+ 'no CMakeLists.txt — upstream uses meson. Same workaround as ' +
58
+ 'autotools: ship a wrapper CMakeLists.txt in your fork.',
59
+ );
60
+ } else {
61
+ notes.push(
62
+ 'no CMakeLists.txt and no recognised build system — likely a header-' +
63
+ 'drop upstream (e.g. imgui, nuklear, stb). Add a small CMakeLists.txt ' +
64
+ 'in the fork that declares an INTERFACE target + install() rules.',
65
+ );
66
+ }
67
+ return result;
68
+ }
69
+
70
+ const kindFromCmake = detectKindFromCMakeLists(cmakePath);
71
+ result.kind = kindFromCmake.kind;
72
+ if (kindFromCmake.note) notes.push(kindFromCmake.note);
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Scan a CMakeLists.txt for the most specific `add_library(<name> <type>)`
78
+ * signal we can find. In order of preference we look for:
79
+ * INTERFACE → HeaderOnlyLib
80
+ * SHARED → DynamicLib
81
+ * STATIC → StaticLib
82
+ * MODULE → StaticLib (rare; treat as static for now)
83
+ *
84
+ * If we find `add_library(<name>)` with no explicit type, CMake's default
85
+ * depends on `BUILD_SHARED_LIBS`. wyvrnpm's convention is to default to
86
+ * `StaticLib` — the consumer can flip via an `options.shared` toggle later.
87
+ *
88
+ * `add_executable(...)` with no `add_library(...)` → the repo ships only a
89
+ * tool, not a library. We return `unknown` so the TODO report surfaces it.
90
+ *
91
+ * @param {string} cmakePath
92
+ * @returns {{ kind: DetectResult['kind'], note?: string }}
93
+ */
94
+ function detectKindFromCMakeLists(cmakePath) {
95
+ let text;
96
+ try { text = fs.readFileSync(cmakePath, 'utf8'); }
97
+ catch { return { kind: 'unknown', note: `could not read ${cmakePath}` }; }
98
+
99
+ // Strip line comments so an old "# add_library(foo SHARED ...)" doesn't
100
+ // poison detection. Block comments in CMake (#[[ ... ]]) are rare; not
101
+ // worth handling here.
102
+ const stripped = text.replace(/#[^\n]*/g, '');
103
+
104
+ const has = (re) => re.test(stripped);
105
+
106
+ // add_library(<name> [STATIC|SHARED|MODULE|OBJECT|INTERFACE|IMPORTED] ...)
107
+ if (has(/add_library\s*\(\s*[A-Za-z0-9_.+-]+\s+INTERFACE\b/i)) {
108
+ return { kind: 'HeaderOnlyLib' };
109
+ }
110
+ if (has(/add_library\s*\(\s*[A-Za-z0-9_.+-]+\s+SHARED\b/i)) {
111
+ return {
112
+ kind: 'DynamicLib',
113
+ note: 'detected SHARED library — consider declaring an `options.shared` toggle if the upstream respects BUILD_SHARED_LIBS.',
114
+ };
115
+ }
116
+ if (has(/add_library\s*\(\s*[A-Za-z0-9_.+-]+\s+STATIC\b/i)) {
117
+ return { kind: 'StaticLib' };
118
+ }
119
+ if (has(/add_library\s*\(\s*[A-Za-z0-9_.+-]+\s+(MODULE|OBJECT)\b/i)) {
120
+ return {
121
+ kind: 'StaticLib',
122
+ note: 'detected MODULE/OBJECT library — treating as StaticLib. Review.',
123
+ };
124
+ }
125
+ // Implicit-default form: `add_library(<name> <sources>...)` — no type
126
+ // keyword after the name. Negative lookahead on the recognised types
127
+ // distinguishes this from the explicit-type cases above (which we've
128
+ // already checked and returned from).
129
+ if (has(/add_library\s*\(\s*[A-Za-z0-9_.+-]+\s+(?!(STATIC|SHARED|MODULE|OBJECT|INTERFACE|IMPORTED)\b)/i)) {
130
+ return {
131
+ kind: 'StaticLib',
132
+ note: 'add_library() with no explicit type — defaulting to StaticLib. ' +
133
+ 'If the upstream respects BUILD_SHARED_LIBS, add an `options.shared` toggle.',
134
+ };
135
+ }
136
+ if (has(/add_executable\s*\(/i)) {
137
+ return {
138
+ kind: 'unknown',
139
+ note: 'repo declares add_executable() but no add_library() — looks like ' +
140
+ 'a tool, not a library. Set kind: ConsoleApp in the manifest.',
141
+ };
142
+ }
143
+ return {
144
+ kind: 'unknown',
145
+ note: 'no add_library() / add_executable() found at the root CMakeLists.txt. ' +
146
+ 'The library may be declared in a subdirectory — inspect and set kind manually.',
147
+ };
148
+ }
149
+
150
+ module.exports = { detectFromRepo, detectKindFromCMakeLists };
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ const { tagToVersion, nameFromUrl } = require('./version');
8
+ const { detectFromRepo } = require('./detect');
9
+ const { lookupCookbook } = require('./cookbook');
10
+ const log = require('../logger');
11
+
12
+ /**
13
+ * Clone `gitUrl` into `destDir` so bootstrap can read the tree.
14
+ * Defaults to a full clone — bootstrap's target audience is "I forked
15
+ * these repos to maintain them", so they want the history. `--shallow`
16
+ * opts into `--depth 1` (faster, but `git describe --tags` will miss
17
+ * tags that aren't reachable from the single fetched commit, so the
18
+ * version detection degrades to HEAD SHA placeholder + TODO).
19
+ *
20
+ * Uses the user's ambient git auth (SSH keys, credential helper). We do
21
+ * NOT manage credentials here.
22
+ *
23
+ * @param {{ gitUrl: string, destDir: string, ref?: string | null, shallow?: boolean }} opts
24
+ */
25
+ function cloneRepo({ gitUrl, destDir, ref = null, shallow = false }) {
26
+ const args = ['clone'];
27
+ if (shallow) args.push('--depth', '1');
28
+ if (ref) args.push('--branch', ref);
29
+ args.push('--', gitUrl, destDir);
30
+ log.info(` git ${args.join(' ')}`);
31
+ execFileSync('git', args, { stdio: 'inherit' });
32
+ }
33
+
34
+ /**
35
+ * Read HEAD SHA + nearest tag out of an existing clone. Pure
36
+ * read-side — never writes. Returns nulls on failure so the caller
37
+ * can surface a TODO rather than abort.
38
+ *
39
+ * @param {string} repoDir
40
+ * @returns {{ sha: string | null, tag: string | null }}
41
+ */
42
+ function readGitState(repoDir) {
43
+ const safe = (args) => {
44
+ try {
45
+ return execFileSync('git', args, { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] })
46
+ .toString('utf8').trim() || null;
47
+ } catch { return null; }
48
+ };
49
+ return {
50
+ sha: safe(['rev-parse', 'HEAD']),
51
+ // `--abbrev=0` gives the nearest tag without the "-<N>-g<sha>" suffix.
52
+ tag: safe(['describe', '--tags', '--abbrev=0']),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Compose a first-draft `wyvrn.json` from detection + cookbook data.
58
+ * Pure: no filesystem writes, no network. `compose` is the unit-testable
59
+ * seam between orchestration (`bootstrap`) and persistence.
60
+ *
61
+ * Precedence for each field (highest wins):
62
+ * CLI override → cookbook hint → filesystem detection → safe default.
63
+ *
64
+ * @param {{
65
+ * url: string,
66
+ * repoDir: string,
67
+ * overrides?: { name?: string, version?: string, kind?: string, description?: string },
68
+ * detect?: ReturnType<typeof detectFromRepo>,
69
+ * gitState?: { sha: string | null, tag: string | null },
70
+ * }} args
71
+ * @returns {{ manifest: object, todos: string[] }}
72
+ */
73
+ function compose({ url, repoDir, overrides = {}, detect = null, gitState = null }) {
74
+ const todos = [];
75
+ const detection = detect ?? detectFromRepo(repoDir);
76
+ const { sha, tag } = gitState ?? { sha: null, tag: null };
77
+
78
+ const repoTail = nameFromUrl(url);
79
+ const cookbook = lookupCookbook(repoTail) ?? {};
80
+
81
+ const name = overrides.name || repoTail;
82
+ if (!name) todos.push('name could not be derived from the URL — set "name" manually.');
83
+
84
+ let version = overrides.version || null;
85
+ if (!version && tag) {
86
+ version = tagToVersion(tag);
87
+ if (!version) {
88
+ todos.push(`nearest tag "${tag}" doesn't match a clean numeric version — set "version" manually (used HEAD SHA as a placeholder).`);
89
+ }
90
+ }
91
+ if (!version) {
92
+ version = '0.0.0.0';
93
+ todos.push('no usable git tag found — "version" defaulted to 0.0.0.0. Pin to a real release before publish.');
94
+ }
95
+
96
+ const kind = overrides.kind || cookbook.kind || detection.kind;
97
+ if (kind === 'unknown') {
98
+ todos.push('kind could not be detected — set "kind" to ConsoleApp | StaticLib | DynamicLib | HeaderOnlyLib | WebApp | Utility | Service | TestProject.');
99
+ }
100
+ for (const note of detection.notes) todos.push(`detect: ${note}`);
101
+ for (const note of (cookbook.notes ?? [])) todos.push(`cookbook: ${note}`);
102
+
103
+ // Safe default recipe for any CMake project. Header-only still goes
104
+ // through cmake --install so the INTERFACE target + install() rules
105
+ // land in the published zip.
106
+ const build = {
107
+ system: 'cmake',
108
+ configs: ['Debug', 'Release', 'RelWithDebInfo', 'MinSizeRel'],
109
+ configure: cookbook.configure ? [...cookbook.configure] : [],
110
+ installDir: 'install',
111
+ };
112
+
113
+ const manifest = {
114
+ name,
115
+ version,
116
+ schemaVersion: 2,
117
+ kind: kind === 'unknown' ? 'StaticLib' : kind,
118
+ description: overrides.description || `TODO: one-line description of ${name}`,
119
+ dependencies: cookbook.dependencies ? { ...cookbook.dependencies } : {},
120
+ build,
121
+ };
122
+
123
+ if (cookbook.dependencies) {
124
+ todos.push(
125
+ `dependencies seeded from cookbook: ${Object.keys(cookbook.dependencies).join(', ')}. ` +
126
+ `Verify versions and pin ranges before publish.`,
127
+ );
128
+ }
129
+
130
+ // `gitRepo` / `gitSha` are normally captured by `wyvrnpm publish` at
131
+ // publish time — we leave them out of the manifest (not an author
132
+ // field) but surface them in the TODO report so the user can
133
+ // double-check the clone landed at the ref they expected.
134
+ if (sha) todos.push(`git HEAD at bootstrap: ${sha}`);
135
+ if (tag) todos.push(`nearest git tag: ${tag}`);
136
+
137
+ return { manifest, todos };
138
+ }
139
+
140
+ /**
141
+ * Full bootstrap flow: optionally clone, read git state, compose manifest,
142
+ * write it to disk. Returns the result so callers (CLI, tests) can decide
143
+ * what to print.
144
+ *
145
+ * @param {{
146
+ * url: string,
147
+ * dest: string,
148
+ * ref?: string | null,
149
+ * name?: string,
150
+ * version?: string,
151
+ * kind?: string,
152
+ * description?: string,
153
+ * noClone?: boolean,
154
+ * dryRun?: boolean,
155
+ * force?: boolean,
156
+ * }} opts
157
+ * @returns {{ manifest: object, todos: string[], manifestPath: string, wrote: boolean }}
158
+ */
159
+ function bootstrap(opts) {
160
+ const {
161
+ url, dest, ref = null,
162
+ name, version, kind, description,
163
+ noClone = false, dryRun = false, force = false, shallow = false,
164
+ } = opts;
165
+
166
+ if (!url && !noClone) throw new Error('bootstrap: --url (or a positional git URL) is required unless --no-clone');
167
+ if (!dest) throw new Error('bootstrap: --dest (or positional) is required');
168
+
169
+ const repoDir = path.resolve(dest);
170
+
171
+ if (!noClone) {
172
+ if (fs.existsSync(repoDir) && !force) {
173
+ throw new Error(
174
+ `bootstrap: destination already exists: ${repoDir}\n` +
175
+ ' Pass --force to reuse it, or --no-clone to operate on it in place.',
176
+ );
177
+ }
178
+ if (fs.existsSync(repoDir) && force) {
179
+ fs.rmSync(repoDir, { recursive: true, force: true });
180
+ }
181
+ cloneRepo({ gitUrl: url, destDir: repoDir, ref, shallow });
182
+ } else if (!fs.existsSync(repoDir)) {
183
+ throw new Error(`bootstrap: --no-clone but directory does not exist: ${repoDir}`);
184
+ }
185
+
186
+ const detect = detectFromRepo(repoDir);
187
+ const gitState = readGitState(repoDir);
188
+
189
+ const { manifest, todos } = compose({
190
+ url: url || gitRemoteUrl(repoDir) || '',
191
+ repoDir,
192
+ overrides: { name, version, kind, description },
193
+ detect,
194
+ gitState,
195
+ });
196
+
197
+ const manifestPath = path.join(repoDir, 'wyvrn.json');
198
+ let wrote = false;
199
+ if (!dryRun) {
200
+ if (fs.existsSync(manifestPath) && !force) {
201
+ throw new Error(
202
+ `bootstrap: ${manifestPath} already exists. Pass --force to overwrite, or --dry-run to preview only.`,
203
+ );
204
+ }
205
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
206
+ wrote = true;
207
+ }
208
+
209
+ return { manifest, todos, manifestPath, wrote };
210
+ }
211
+
212
+ function gitRemoteUrl(repoDir) {
213
+ try {
214
+ return execFileSync('git', ['remote', 'get-url', 'origin'], {
215
+ cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'],
216
+ }).toString('utf8').trim() || null;
217
+ } catch { return null; }
218
+ }
219
+
220
+ module.exports = { bootstrap, compose, readGitState, cloneRepo };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parse an upstream git tag into wyvrnpm's 4-part version scheme.
5
+ *
6
+ * Most C++ OSS projects publish 3-part semver tags (`v1.2.3`, `11.0.2`,
7
+ * `1.3.12`). wyvrnpm uses a 4-part `major.minor.patch.build` scheme
8
+ * throughout (see CLAUDE.md §4.2). We pad the upstream tag with `.0` for
9
+ * the build component so published manifests look like `1.3.12.0`.
10
+ *
11
+ * Accepted tag shapes:
12
+ * v1.2.3 → "1.2.3.0"
13
+ * 1.2.3 → "1.2.3.0"
14
+ * v1.2.3.4 → "1.2.3.4"
15
+ * v11.0 → "11.0.0.0" (2-part — padded)
16
+ * v11 → "11.0.0.0" (1-part — padded)
17
+ * release-1.2 → "1.2.0.0" (strips a leading "release-" / "ver-")
18
+ *
19
+ * Rejected (returns null):
20
+ * empty / non-string
21
+ * tags with suffixes ("1.2.3-rc1", "1.2.3+git", "1.2.3a") — callers
22
+ * fall back to the HEAD SHA and flag a TODO
23
+ * tags with 5+ numeric components
24
+ *
25
+ * @param {string | null} tag
26
+ * @returns {string | null} 4-part version, or null when the tag can't be
27
+ * cleanly promoted.
28
+ */
29
+ function tagToVersion(tag) {
30
+ if (typeof tag !== 'string') return null;
31
+ let t = tag.trim();
32
+ if (!t) return null;
33
+
34
+ // Strip the most common prefixes authors use on git tags. Longest
35
+ // alternatives first so `version-2.0` matches the whole word, not
36
+ // just the leading `v`. We keep the allowlist tight — we'd rather
37
+ // fall through than silently misparse `release-candidate-1.2.3`.
38
+ t = t.replace(/^(version|release|ver|v)[-_.]?/i, '');
39
+
40
+ // Must be bare numeric segments separated by dots, nothing else.
41
+ if (!/^\d+(\.\d+){0,3}$/.test(t)) return null;
42
+
43
+ const parts = t.split('.').map((n) => parseInt(n, 10));
44
+ if (parts.some((n) => !Number.isFinite(n) || n < 0)) return null;
45
+
46
+ while (parts.length < 4) parts.push(0);
47
+ return parts.join('.');
48
+ }
49
+
50
+ /**
51
+ * Derive a wyvrnpm package name from a git URL.
52
+ *
53
+ * https://github.com/fmtlib/fmt.git → "fmt"
54
+ * git@github.com:fmtlib/fmt.git → "fmt"
55
+ * https://gitlab.com/bzip2/bzip2.git → "bzip2"
56
+ * https://github.com/Tencent/rapidjson.git → "rapidjson"
57
+ * https://github.com/nlohmann/json.git → "json" (caller may want "nlohmann-json")
58
+ *
59
+ * Pure: does not hit the network.
60
+ *
61
+ * @param {string} url
62
+ * @returns {string}
63
+ */
64
+ function nameFromUrl(url) {
65
+ if (typeof url !== 'string' || !url.trim()) return '';
66
+ const trimmed = url.trim().replace(/\/$/, '');
67
+ // Split on the last slash or colon (SSH form).
68
+ const tail = trimmed.split(/[/:]/).pop() || '';
69
+ return tail.replace(/\.git$/i, '');
70
+ }
71
+
72
+ module.exports = { tagToVersion, nameFromUrl };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const { bootstrap } = require('../bootstrap');
6
+ const { nameFromUrl } = require('../bootstrap/version');
7
+ const log = require('../logger');
8
+
9
+ /**
10
+ * `wyvrnpm bootstrap <git-url>` — scaffold a first-draft `wyvrn.json`
11
+ * inside a cloned OSS repo so it can be built + published as a wyvrnpm
12
+ * package. See CLAUDE.md §5.9 and
13
+ * claude/skills/wyvrnpm/references/oss-conversion-patterns.md.
14
+ *
15
+ * Typical use — batch-convert a list of OSS libraries into wyvrnpm:
16
+ *
17
+ * while read url; do
18
+ * wyvrnpm bootstrap "$url" --dest "./pkgs/$(basename "$url" .git)"
19
+ * done < urls.txt
20
+ *
21
+ * The output is always a *first draft* — bootstrap prints a TODO report
22
+ * flagging gotchas that need human review (install gates, test-skip
23
+ * flags upstream-specific, missing CMakeLists.txt on header-drop repos
24
+ * like stb/imgui). Always review before `wyvrnpm publish`.
25
+ *
26
+ * @param {object} argv
27
+ */
28
+ async function bootstrapCmd(argv) {
29
+ const url = argv.url ?? argv._?.[1] ?? null;
30
+ if (!url && !argv.noClone) {
31
+ log.error('bootstrap: missing git URL.\n Usage: wyvrnpm bootstrap <git-url> [--dest <dir>]');
32
+ process.exit(1);
33
+ }
34
+
35
+ const dest = argv.dest || path.resolve(nameFromUrl(url ?? '') || 'package');
36
+
37
+ let result;
38
+ try {
39
+ result = bootstrap({
40
+ url,
41
+ dest,
42
+ ref: argv.ref,
43
+ name: argv.name,
44
+ version: argv.version,
45
+ kind: argv.kind,
46
+ description: argv.description,
47
+ noClone: Boolean(argv.noClone),
48
+ dryRun: Boolean(argv.dryRun),
49
+ force: Boolean(argv.force),
50
+ });
51
+ } catch (err) {
52
+ log.error(err.message);
53
+ process.exit(1);
54
+ }
55
+
56
+ const { manifest, todos, manifestPath, wrote } = result;
57
+
58
+ if (argv.format === 'json') {
59
+ process.stdout.write(JSON.stringify({
60
+ command: 'bootstrap',
61
+ wyvrnpmVersion: require('../../package.json').version,
62
+ dest,
63
+ manifestPath,
64
+ wrote,
65
+ manifest,
66
+ todos,
67
+ }, null, 2) + '\n');
68
+ return;
69
+ }
70
+
71
+ console.log();
72
+ console.log(`─── ${manifest.name}@${manifest.version} (${manifest.kind}) ───`);
73
+ console.log(JSON.stringify(manifest, null, 2));
74
+ console.log();
75
+
76
+ if (wrote) log.success(`Wrote ${manifestPath}`);
77
+ else log.info(`Dry run — no manifest written.`);
78
+
79
+ if (todos.length > 0) {
80
+ console.log();
81
+ console.log('TODOs — review before `wyvrnpm publish`:');
82
+ for (const t of todos) console.log(` • ${t}`);
83
+ }
84
+
85
+ if (wrote) {
86
+ console.log();
87
+ console.log('Next steps:');
88
+ console.log(` cd ${path.relative(process.cwd(), path.resolve(dest)) || '.'}`);
89
+ console.log(' wyvrnpm install');
90
+ console.log(' wyvrnpm build --install --all-configs');
91
+ console.log(' # review the install tree, then:');
92
+ console.log(' wyvrnpm publish --source <your-s3-publish-source>');
93
+ }
94
+ }
95
+
96
+ module.exports = bootstrapCmd;