wyvrnpm 2.9.0 → 2.10.2
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 +61 -0
- package/bin/wyvrn.js +95 -1
- package/claude/skills/wyvrnpm.skill +0 -0
- package/package.json +1 -1
- package/src/bootstrap/cookbook.js +196 -0
- package/src/bootstrap/detect.js +150 -0
- package/src/bootstrap/index.js +220 -0
- package/src/bootstrap/version.js +72 -0
- package/src/commands/bootstrap.js +96 -0
- package/src/commands/publish.js +120 -0
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.
|
|
@@ -375,6 +431,10 @@ wyvrnpm publish --path ./dist
|
|
|
375
431
|
|
|
376
432
|
# Overwrite an existing build (same version + profile hash)
|
|
377
433
|
wyvrnpm publish --force
|
|
434
|
+
|
|
435
|
+
# Dry-run — show the upload plan without touching the registry
|
|
436
|
+
wyvrnpm publish --dry-run
|
|
437
|
+
wyvrnpm publish --dry-run --format json | jq '.wouldOverwrite, .plan'
|
|
378
438
|
```
|
|
379
439
|
|
|
380
440
|
**Options**
|
|
@@ -389,6 +449,7 @@ wyvrnpm publish --force
|
|
|
389
449
|
| `--path` | `.` | Directory to zip and publish. |
|
|
390
450
|
| `--manifest` | `./wyvrn.json` | Path to the manifest file. |
|
|
391
451
|
| `--force` / `-f` | `false` | Overwrite an existing published version (same version + profile hash). |
|
|
452
|
+
| `--dry-run` | `false` | Run every step (profile, options, zip, SHA256, `v2Exists` check) and print the upload plan, but skip the provider write. An existing published version is reported as a warning (not an error) so the full plan still prints. Pair with `--format json` to gate CI on `wouldOverwrite`. |
|
|
392
453
|
|
|
393
454
|
During publish the tool also:
|
|
394
455
|
- reads `wyvrn.lock` to pin locked dependency versions into the uploaded manifest
|
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...>',
|
|
@@ -451,6 +529,17 @@ yargs
|
|
|
451
529
|
description: 'Overwrite an existing published version',
|
|
452
530
|
default: false,
|
|
453
531
|
})
|
|
532
|
+
.option('dry-run', {
|
|
533
|
+
type: 'boolean',
|
|
534
|
+
default: false,
|
|
535
|
+
description:
|
|
536
|
+
'Mirror of `npm publish --dry-run` — run every step (profile, ' +
|
|
537
|
+
'options, zip, SHA256, v2Exists check) and print the upload plan, ' +
|
|
538
|
+
'but skip the provider write. An existing published version is ' +
|
|
539
|
+
'reported as a warning instead of failing. Pair with --format json ' +
|
|
540
|
+
'to script bulk publish validation (plan + wouldOverwrite flag ' +
|
|
541
|
+
'land in stdout).',
|
|
542
|
+
})
|
|
454
543
|
.option('option', {
|
|
455
544
|
alias: 'o',
|
|
456
545
|
type: 'string',
|
|
@@ -476,7 +565,12 @@ yargs
|
|
|
476
565
|
})
|
|
477
566
|
;
|
|
478
567
|
},
|
|
479
|
-
(argv) => publish({
|
|
568
|
+
(argv) => publish({
|
|
569
|
+
...argv,
|
|
570
|
+
awsProfile: argv['aws-profile'],
|
|
571
|
+
tokenEnv: argv['token-env'],
|
|
572
|
+
dryRun: argv['dry-run'],
|
|
573
|
+
}),
|
|
480
574
|
)
|
|
481
575
|
|
|
482
576
|
// ── configure ─────────────────────────────────────────────────────────────
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -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;
|
package/src/commands/publish.js
CHANGED
|
@@ -22,6 +22,59 @@ const { loadIgnorePatterns, isIgnored } = require('../ignore'
|
|
|
22
22
|
const { hasBuildRecipe, normalizeRecipe } = require('../build/recipe');
|
|
23
23
|
const log = require('../logger');
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Human-friendly byte formatter for the dry-run summary.
|
|
27
|
+
*
|
|
28
|
+
* @param {number} n
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function formatBytes(n) {
|
|
32
|
+
if (!Number.isFinite(n) || n < 0) return `${n}`;
|
|
33
|
+
if (n < 1024) return `${n} B`;
|
|
34
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
35
|
+
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
36
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compute the registry URLs/paths that `v2Publish` would write to. Pure —
|
|
41
|
+
* no network, no filesystem. Mirrors the keys produced by S3/HTTP/File
|
|
42
|
+
* providers (§4.5 of CLAUDE.md), so dry-run can surface the exact shape
|
|
43
|
+
* of the upload without actually performing it.
|
|
44
|
+
*
|
|
45
|
+
* @param {{
|
|
46
|
+
* source: string,
|
|
47
|
+
* name: string,
|
|
48
|
+
* version: string,
|
|
49
|
+
* profileHash: string,
|
|
50
|
+
* gitSha?: string | null,
|
|
51
|
+
* gitRepo?: string | null,
|
|
52
|
+
* updateLatest?: boolean,
|
|
53
|
+
* }} opts
|
|
54
|
+
* @returns {Array<{ path: string, kind: 'wyvrn.zip'|'wyvrn.json'|'source.json'|'versions.json'|'latest.json' }>}
|
|
55
|
+
*/
|
|
56
|
+
function planPublishUrls({
|
|
57
|
+
source, name, version, profileHash,
|
|
58
|
+
gitSha = null, gitRepo = null, updateLatest = true,
|
|
59
|
+
}) {
|
|
60
|
+
const base = source.replace(/\/$/, '');
|
|
61
|
+
const v2root = `${base}/v2/${name}`;
|
|
62
|
+
const buildRoot = `${v2root}/${version}/${profileHash}`;
|
|
63
|
+
|
|
64
|
+
const plan = [
|
|
65
|
+
{ path: `${buildRoot}/wyvrn.zip`, kind: 'wyvrn.zip' },
|
|
66
|
+
{ path: `${buildRoot}/wyvrn.json`, kind: 'wyvrn.json' },
|
|
67
|
+
];
|
|
68
|
+
if (gitSha || gitRepo) {
|
|
69
|
+
plan.push({ path: `${v2root}/${version}/source.json`, kind: 'source.json' });
|
|
70
|
+
}
|
|
71
|
+
plan.push({ path: `${v2root}/versions.json`, kind: 'versions.json' });
|
|
72
|
+
if (updateLatest) {
|
|
73
|
+
plan.push({ path: `${v2root}/latest.json`, kind: 'latest.json' });
|
|
74
|
+
}
|
|
75
|
+
return plan;
|
|
76
|
+
}
|
|
77
|
+
|
|
25
78
|
/**
|
|
26
79
|
* Walk `dir` recursively and add every file to `zip`, skipping anything that
|
|
27
80
|
* matches `.wyvrnignore` patterns OR whose zip-relative path is already in
|
|
@@ -146,7 +199,11 @@ async function publish(argv) {
|
|
|
146
199
|
|
|
147
200
|
const { source, awsProfile, token } = resolveSource(argv, ctx);
|
|
148
201
|
const { path: publishPath, force } = argv;
|
|
202
|
+
const dryRun = Boolean(argv.dryRun);
|
|
149
203
|
const jsonOut = ctx.flags.format === 'json';
|
|
204
|
+
if (dryRun) {
|
|
205
|
+
log.info('DRY RUN — no upload will occur. Previewing publish plan only.');
|
|
206
|
+
}
|
|
150
207
|
|
|
151
208
|
// ── Load manifest ─────────────────────────────────────────────────────────
|
|
152
209
|
// ctx.manifest is a lazy getter — throws on first access if the file is
|
|
@@ -221,10 +278,18 @@ async function publish(argv) {
|
|
|
221
278
|
);
|
|
222
279
|
|
|
223
280
|
// ── Check v2 existence ────────────────────────────────────────────────────
|
|
281
|
+
// Dry-run softens the overwrite-without-force error to a warning so the
|
|
282
|
+
// full plan still prints — CI callers should parse `--format json` and
|
|
283
|
+
// gate on `wouldOverwrite` rather than on the exit code.
|
|
224
284
|
const v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
|
|
225
285
|
if (v2AlreadyExists) {
|
|
226
286
|
if (force) {
|
|
227
287
|
log.warn(`${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
|
|
288
|
+
} else if (dryRun) {
|
|
289
|
+
log.warn(
|
|
290
|
+
`${name}@${version} [${profileHash}] already exists on the registry. ` +
|
|
291
|
+
`A real publish would refuse without --force.`,
|
|
292
|
+
);
|
|
228
293
|
} else {
|
|
229
294
|
log.error(
|
|
230
295
|
`${name}@${version} [${profileHash}] already exists.\n` +
|
|
@@ -380,7 +445,60 @@ async function publish(argv) {
|
|
|
380
445
|
zip.writeZip(tmpZipPath);
|
|
381
446
|
|
|
382
447
|
const contentSha256 = sha256Of(tmpZipPath);
|
|
448
|
+
const zipBytes = fs.statSync(tmpZipPath).size;
|
|
449
|
+
const manifestBytes = fs.statSync(tmpManifestPath).size;
|
|
383
450
|
log.info(`Content SHA256 : ${contentSha256}`);
|
|
451
|
+
log.info(`Zip size : ${formatBytes(zipBytes)} (${zipBytes} bytes)`);
|
|
452
|
+
|
|
453
|
+
// ── Dry run: print the plan, skip the actual upload ──────────────────
|
|
454
|
+
// Mirrors `npm publish --dry-run` — every step above (profile, options,
|
|
455
|
+
// zip, hash, v2Exists) runs; only the provider write is skipped.
|
|
456
|
+
if (dryRun) {
|
|
457
|
+
const plan = planPublishUrls({
|
|
458
|
+
source, name, version, profileHash, gitSha, gitRepo, updateLatest: true,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
if (!jsonOut) {
|
|
462
|
+
console.log();
|
|
463
|
+
console.log(`Would publish ${name}@${version} [${profileHash}] to ${source}`);
|
|
464
|
+
console.log(` contentSha256 : ${contentSha256}`);
|
|
465
|
+
console.log(` zip size : ${formatBytes(zipBytes)}`);
|
|
466
|
+
console.log(` manifest size : ${formatBytes(manifestBytes)}`);
|
|
467
|
+
if (v2AlreadyExists) {
|
|
468
|
+
console.log(` overwrite : YES — ${force ? '--force set, would proceed' : 'NOT SET, real publish would refuse'}`);
|
|
469
|
+
}
|
|
470
|
+
console.log();
|
|
471
|
+
console.log('Registry writes:');
|
|
472
|
+
for (const { path: p, kind } of plan) {
|
|
473
|
+
console.log(` → ${p} (${kind})`);
|
|
474
|
+
}
|
|
475
|
+
console.log();
|
|
476
|
+
log.success('Dry run OK — nothing uploaded.');
|
|
477
|
+
} else {
|
|
478
|
+
const payload = {
|
|
479
|
+
command: 'publish',
|
|
480
|
+
dryRun: true,
|
|
481
|
+
wyvrnpmVersion: require('../../package.json').version,
|
|
482
|
+
name,
|
|
483
|
+
version,
|
|
484
|
+
profileHash,
|
|
485
|
+
source,
|
|
486
|
+
contentSha256,
|
|
487
|
+
zipBytes,
|
|
488
|
+
manifestBytes,
|
|
489
|
+
options: effectiveOptions ?? null,
|
|
490
|
+
gitSha: gitSha ?? null,
|
|
491
|
+
gitRepo: gitRepo ?? null,
|
|
492
|
+
wouldOverwrite: Boolean(v2AlreadyExists),
|
|
493
|
+
forceSet: Boolean(force),
|
|
494
|
+
publishedLock,
|
|
495
|
+
publishedConf: Object.keys(publishedConf).length > 0 ? unflattenConf(publishedConf) : null,
|
|
496
|
+
plan,
|
|
497
|
+
};
|
|
498
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
384
502
|
|
|
385
503
|
// ── v2 publish ────────────────────────────────────────────────────────
|
|
386
504
|
log.info('Publishing (v2) ...');
|
|
@@ -433,3 +551,5 @@ module.exports = publish;
|
|
|
433
551
|
module.exports.addFolderFiltered = addFolderFiltered;
|
|
434
552
|
module.exports.addInstallTree = addInstallTree;
|
|
435
553
|
module.exports.collectInstallTreeFiles = collectInstallTreeFiles;
|
|
554
|
+
module.exports.planPublishUrls = planPublishUrls;
|
|
555
|
+
module.exports.formatBytes = formatBytes;
|