wyvrnpm 2.10.2 → 2.12.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.
Files changed (49) hide show
  1. package/README.md +1914 -1860
  2. package/bin/{wyvrn.js → wyvrnpm.js} +66 -0
  3. package/cmake/cpp.cmake +9 -9
  4. package/cmake/functions.cmake +224 -224
  5. package/cmake/macros.cmake +284 -284
  6. package/package.json +3 -2
  7. package/src/auth.js +66 -66
  8. package/src/binary-dir.js +95 -0
  9. package/src/bootstrap/cookbook.js +196 -196
  10. package/src/bootstrap/detect.js +150 -150
  11. package/src/bootstrap/index.js +220 -220
  12. package/src/bootstrap/version.js +72 -72
  13. package/src/build/cache.js +344 -344
  14. package/src/build/clone.js +170 -170
  15. package/src/build/cmake.js +342 -342
  16. package/src/build/index.js +299 -297
  17. package/src/build/msvc-env.js +260 -260
  18. package/src/build/recipe.js +188 -188
  19. package/src/commands/add.js +141 -141
  20. package/src/commands/bootstrap.js +96 -96
  21. package/src/commands/build.js +482 -452
  22. package/src/commands/cache.js +189 -189
  23. package/src/commands/clean.js +80 -80
  24. package/src/commands/configure.js +92 -92
  25. package/src/commands/init.js +70 -70
  26. package/src/commands/install-skill.js +115 -115
  27. package/src/commands/install.js +730 -674
  28. package/src/commands/link.js +320 -320
  29. package/src/commands/profile.js +237 -237
  30. package/src/commands/publish.js +584 -555
  31. package/src/commands/show.js +252 -252
  32. package/src/commands/version.js +187 -0
  33. package/src/compat.js +273 -273
  34. package/src/conf/index.js +415 -391
  35. package/src/conf/namespaces.js +94 -94
  36. package/src/context.js +230 -230
  37. package/src/http-fetch.js +53 -53
  38. package/src/ignore.js +118 -118
  39. package/src/logger.js +122 -122
  40. package/src/options.js +303 -303
  41. package/src/settings-overrides.js +152 -152
  42. package/src/toolchain/deps.js +164 -164
  43. package/src/toolchain/index.js +212 -212
  44. package/src/toolchain/presets.js +356 -340
  45. package/src/toolchain/template.cmake +77 -77
  46. package/src/upload-built.js +265 -265
  47. package/src/version-range.js +301 -301
  48. package/src/zip-safe.js +52 -52
  49. package/src/zip-stream.js +126 -0
@@ -1,220 +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 };
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 };
@@ -1,72 +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 };
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 };