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,94 +1,94 @@
1
- 'use strict';
2
-
3
- // Allow-list of recognised `conf` namespaces (PLAN-CONF.md §4.1).
4
- //
5
- // Adding a new namespace is an intentional act — the plan commits to
6
- // expanding this file only when a concrete use case lands, not
7
- // speculatively. Phase 1 ships `cmake.cache.*` only; every other
8
- // namespace (env.*, build.*, tool.*) is reserved but not yet valid.
9
- //
10
- // Each entry describes how leaf names under that namespace are
11
- // validated. A namespace with `leaf: true` accepts any non-empty leaf
12
- // name; a namespace with `leaf: /regex/` enforces the regex at load
13
- // time. The regex only validates the leaf name — leaf VALUES are
14
- // normalised separately in ../conf/index.js.
15
-
16
- /**
17
- * @typedef {Object} NamespaceSpec
18
- * @property {RegExp} leaf
19
- * Pattern that every leaf name under this namespace must satisfy.
20
- * @property {string} description
21
- * Human-readable one-liner shown in error messages.
22
- */
23
-
24
- /** @type {Record<string, NamespaceSpec>} */
25
- const NAMESPACES = {
26
- 'cmake.cache': {
27
- // CMake variable names: C/C++ identifier rules, no hyphens.
28
- leaf: /^[A-Za-z_][A-Za-z0-9_]*$/,
29
- description: 'CMake cache variables — baked into CMakePresets.json cacheVariables.',
30
- },
31
- };
32
-
33
- /**
34
- * Parse a full dotted conf key (e.g. `cmake.cache.CHROMA_ENABLE_TEST`)
35
- * into its namespace prefix and leaf name. The namespace is the
36
- * longest matching prefix registered in NAMESPACES; anything after is
37
- * the leaf.
38
- *
39
- * Returns `{ namespace, leaf }` on success, `null` when the key does
40
- * not belong to any registered namespace or has no leaf component.
41
- *
42
- * @param {string} key
43
- * @returns {{ namespace: string, leaf: string } | null}
44
- */
45
- function splitKey(key) {
46
- // Try longer namespaces first so "a.b.c" prefers namespace "a.b"
47
- // over a hypothetical namespace "a".
48
- const known = Object.keys(NAMESPACES).sort((a, b) => b.length - a.length);
49
- for (const ns of known) {
50
- const prefix = `${ns}.`;
51
- if (key.startsWith(prefix)) {
52
- const leaf = key.slice(prefix.length);
53
- if (leaf.length === 0) return null;
54
- return { namespace: ns, leaf };
55
- }
56
- }
57
- return null;
58
- }
59
-
60
- /**
61
- * Validate a flat `{key: value}` map against the namespace allow-list.
62
- * Throws on the first offender (fail-fast, matches the Zip Slip
63
- * validator's style). Leaf VALUES are not type-checked here — values
64
- * arrive as strings from the CLI, normalised from JSON elsewhere; type
65
- * enforcement happens in ../conf/index.js at merge time.
66
- *
67
- * @param {Record<string, string>} flatConf
68
- * @throws {Error} if any key's namespace or leaf is rejected.
69
- */
70
- function validateFlatConfKeys(flatConf) {
71
- const knownList = Object.keys(NAMESPACES).sort().join(', ');
72
- for (const key of Object.keys(flatConf)) {
73
- const parts = splitKey(key);
74
- if (!parts) {
75
- throw new Error(
76
- `unknown conf namespace in ${JSON.stringify(key)} — ` +
77
- `phase-1 allow-list: [${knownList}]`,
78
- );
79
- }
80
- const spec = NAMESPACES[parts.namespace];
81
- if (!spec.leaf.test(parts.leaf)) {
82
- throw new Error(
83
- `invalid leaf name ${JSON.stringify(parts.leaf)} for conf namespace ` +
84
- `"${parts.namespace}" — ${spec.description}`,
85
- );
86
- }
87
- }
88
- }
89
-
90
- module.exports = {
91
- NAMESPACES,
92
- splitKey,
93
- validateFlatConfKeys,
94
- };
1
+ 'use strict';
2
+
3
+ // Allow-list of recognised `conf` namespaces (PLAN-CONF.md §4.1).
4
+ //
5
+ // Adding a new namespace is an intentional act — the plan commits to
6
+ // expanding this file only when a concrete use case lands, not
7
+ // speculatively. Phase 1 ships `cmake.cache.*` only; every other
8
+ // namespace (env.*, build.*, tool.*) is reserved but not yet valid.
9
+ //
10
+ // Each entry describes how leaf names under that namespace are
11
+ // validated. A namespace with `leaf: true` accepts any non-empty leaf
12
+ // name; a namespace with `leaf: /regex/` enforces the regex at load
13
+ // time. The regex only validates the leaf name — leaf VALUES are
14
+ // normalised separately in ../conf/index.js.
15
+
16
+ /**
17
+ * @typedef {Object} NamespaceSpec
18
+ * @property {RegExp} leaf
19
+ * Pattern that every leaf name under this namespace must satisfy.
20
+ * @property {string} description
21
+ * Human-readable one-liner shown in error messages.
22
+ */
23
+
24
+ /** @type {Record<string, NamespaceSpec>} */
25
+ const NAMESPACES = {
26
+ 'cmake.cache': {
27
+ // CMake variable names: C/C++ identifier rules, no hyphens.
28
+ leaf: /^[A-Za-z_][A-Za-z0-9_]*$/,
29
+ description: 'CMake cache variables — baked into CMakePresets.json cacheVariables.',
30
+ },
31
+ };
32
+
33
+ /**
34
+ * Parse a full dotted conf key (e.g. `cmake.cache.CHROMA_ENABLE_TEST`)
35
+ * into its namespace prefix and leaf name. The namespace is the
36
+ * longest matching prefix registered in NAMESPACES; anything after is
37
+ * the leaf.
38
+ *
39
+ * Returns `{ namespace, leaf }` on success, `null` when the key does
40
+ * not belong to any registered namespace or has no leaf component.
41
+ *
42
+ * @param {string} key
43
+ * @returns {{ namespace: string, leaf: string } | null}
44
+ */
45
+ function splitKey(key) {
46
+ // Try longer namespaces first so "a.b.c" prefers namespace "a.b"
47
+ // over a hypothetical namespace "a".
48
+ const known = Object.keys(NAMESPACES).sort((a, b) => b.length - a.length);
49
+ for (const ns of known) {
50
+ const prefix = `${ns}.`;
51
+ if (key.startsWith(prefix)) {
52
+ const leaf = key.slice(prefix.length);
53
+ if (leaf.length === 0) return null;
54
+ return { namespace: ns, leaf };
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Validate a flat `{key: value}` map against the namespace allow-list.
62
+ * Throws on the first offender (fail-fast, matches the Zip Slip
63
+ * validator's style). Leaf VALUES are not type-checked here — values
64
+ * arrive as strings from the CLI, normalised from JSON elsewhere; type
65
+ * enforcement happens in ../conf/index.js at merge time.
66
+ *
67
+ * @param {Record<string, string>} flatConf
68
+ * @throws {Error} if any key's namespace or leaf is rejected.
69
+ */
70
+ function validateFlatConfKeys(flatConf) {
71
+ const knownList = Object.keys(NAMESPACES).sort().join(', ');
72
+ for (const key of Object.keys(flatConf)) {
73
+ const parts = splitKey(key);
74
+ if (!parts) {
75
+ throw new Error(
76
+ `unknown conf namespace in ${JSON.stringify(key)} — ` +
77
+ `phase-1 allow-list: [${knownList}]`,
78
+ );
79
+ }
80
+ const spec = NAMESPACES[parts.namespace];
81
+ if (!spec.leaf.test(parts.leaf)) {
82
+ throw new Error(
83
+ `invalid leaf name ${JSON.stringify(parts.leaf)} for conf namespace ` +
84
+ `"${parts.namespace}" — ${spec.description}`,
85
+ );
86
+ }
87
+ }
88
+ }
89
+
90
+ module.exports = {
91
+ NAMESPACES,
92
+ splitKey,
93
+ validateFlatConfKeys,
94
+ };
package/src/context.js CHANGED
@@ -1,230 +1,230 @@
1
- 'use strict';
2
-
3
- /**
4
- * CommandContext — the shared per-invocation state every command consumes.
5
- *
6
- * Commands used to each re-read the manifest, re-resolve the profile name,
7
- * re-read config, re-resolve auth, and each toggle JSON mode on the logger.
8
- * The divergence was cheap at 11 commands; it starts costing real bugs
9
- * once F3 (`tool_requires`) and F4 (build/host profile split) double the
10
- * flag surface. See [claude/PLAN-COMMAND-CONTEXT.md](../claude/PLAN-COMMAND-CONTEXT.md).
11
- *
12
- * `buildContext(argv)` runs once at the top of each command. It:
13
- * - resolves filesystem anchors (rootDir, manifestPath)
14
- * - reads config once
15
- * - resolves the profile-name fallback chain + loads the profile
16
- * - parses CLI options + conf (format-level only; namespace allow-
17
- * listing happens later in the sinks that need it)
18
- * - sets JSON mode on the logger if --format=json
19
- * - exposes an auth helper pre-curried with CLI --token / --token-env
20
- * - exposes a lazy manifest getter (show never reads the manifest)
21
- *
22
- * `buildContext` does NOT do source filtering — each command has subtly
23
- * different CLI shape there (publish takes name-or-URL, install/show
24
- * take URL arrays, pin-resolution only matters for install). That
25
- * stays per-command until a second feature shows the same duplication.
26
- *
27
- * Errors: `buildContext` THROWS on all input-validation failures. Each
28
- * command wraps the call in one try/catch that does `log.error + exit`.
29
- * This keeps buildContext unit-testable; behaviour from the user's
30
- * point of view is unchanged.
31
- */
32
-
33
- const fs = require('fs');
34
- const path = require('path');
35
-
36
- const { readConfig } = require('./config');
37
- const { loadProfile } = require('./profile');
38
- const { readManifest } = require('./manifest');
39
- const { resolveSourceAuth } = require('./auth');
40
- const { parseCliOptions } = require('./options');
41
- const { parseCliConf } = require('./conf');
42
- const log = require('./logger');
43
-
44
- // ---------------------------------------------------------------------------
45
- // Internal helpers
46
- // ---------------------------------------------------------------------------
47
-
48
- /**
49
- * Resolve the active profile name per the long-standing precedence:
50
- * --profile > config.defaultProfile > 'default'
51
- */
52
- function resolveProfileName(argv, config) {
53
- if (typeof argv.profile === 'string' && argv.profile.length > 0) {
54
- return argv.profile;
55
- }
56
- if (typeof config.defaultProfile === 'string' && config.defaultProfile.length > 0) {
57
- return config.defaultProfile;
58
- }
59
- return 'default';
60
- }
61
-
62
- /**
63
- * Normalise CLI flags into the stable `ctx.flags` shape. Centralising
64
- * defaults here means individual commands don't each re-apply
65
- * `?? 'never'` / `?? 'Release'` / etc.
66
- *
67
- * Flags that are command-specific (argv.preset, argv.pkg, argv.clean,
68
- * argv.verbose, argv.generator, argv.install, argv.installPrefix,
69
- * argv.uploadSource, argv.autoInstall, argv.noGpgSign, ...) stay on
70
- * argv and commands read them there. `ctx.argv` is the escape hatch.
71
- */
72
- function normaliseFlags(argv) {
73
- return {
74
- format: argv.format === 'json' ? 'json' : 'text',
75
- dryRun: argv.dryRun === true,
76
- build: argv.build ?? 'never',
77
- uploadBuilt: argv.uploadBuilt === true,
78
- force: argv.force === true,
79
- platform: argv.platform ?? defaultPlatform(),
80
- timeout: typeof argv.timeout === 'number' ? argv.timeout : 300,
81
- };
82
- }
83
-
84
- /**
85
- * Default platform string when --platform is absent. Mirrors what
86
- * bin/wyvrn.js passes when yargs applies its own default — keeping
87
- * it here makes context.js testable without exercising the CLI layer.
88
- */
89
- function defaultPlatform() {
90
- if (process.platform === 'win32') {
91
- return process.arch === 'ia32' ? 'win_x86' : 'win_x64';
92
- }
93
- if (process.platform === 'darwin') return 'macos';
94
- return 'linux';
95
- }
96
-
97
- /**
98
- * Produce a frozen `{ host, build }` profile pair. Today both point at
99
- * the same resolved object — when F4 (build/host profile split) lands,
100
- * only this function changes; every command reading `ctx.profiles.host`
101
- * or `ctx.profiles.build` continues to work.
102
- *
103
- * The two sub-objects share a reference today. A regression test in
104
- * tests/context.test.js asserts `ctx.profiles.host === ctx.profiles.build`
105
- * — that test is expected to be removed (not fixed) when F4 makes them
106
- * legitimately distinct.
107
- */
108
- function resolveProfiles(argv, config) {
109
- const name = resolveProfileName(argv, config);
110
- const { profile, fromFile } = loadProfile(name);
111
- const entry = Object.freeze({ name, profile, fromFile });
112
- return Object.freeze({ host: entry, build: entry });
113
- }
114
-
115
- // ---------------------------------------------------------------------------
116
- // buildContext
117
- // ---------------------------------------------------------------------------
118
-
119
- /**
120
- * Build the per-invocation CommandContext.
121
- *
122
- * @param {object} argv - yargs-parsed argument map. Fields read:
123
- * root, manifest, profile, format, dryRun, build, uploadBuilt,
124
- * force, platform, timeout, token, tokenEnv, option, conf.
125
- * Command-specific fields pass through on ctx.argv unchanged.
126
- *
127
- * @returns {CommandContext}
128
- *
129
- * @throws on:
130
- * - parseCliOptions: malformed `-o pkg:name=value`
131
- * - parseCliConf: malformed `--conf KEY=VALUE`
132
- * - loadProfile: malformed profile JSON, cycle in `extends`, etc.
133
- *
134
- * Does NOT throw on:
135
- * - missing manifest (lazy — throws at first ctx.manifest access)
136
- * - missing config (readConfig returns empty defaults)
137
- * - missing profile (loadProfile falls back to detectProfile())
138
- * - missing --token-env value (resolveSourceAuth throws at ctx.auth.for time)
139
- */
140
- function buildContext(argv = {}) {
141
- // 1. Filesystem anchors — pure path math, no I/O.
142
- const rootDir = path.resolve(argv.root ?? '.');
143
- const manifestPath = path.resolve(
144
- argv.manifest ?? path.join(rootDir, 'wyvrn.json'),
145
- );
146
-
147
- // 2. Config — sync, small file, every command needs at least .defaultProfile.
148
- const config = readConfig();
149
-
150
- // 3. Profile (F4-ready shape).
151
- const profiles = resolveProfiles(argv, config);
152
-
153
- // 4. CLI auth, kept as the raw pair. Actual token lookup happens
154
- // through ctx.auth.for(sourceEntry), which runs resolveSourceAuth
155
- // each time so --token-env misses surface at use (same as today).
156
- const authCli = Object.freeze({
157
- token: typeof argv.token === 'string' ? argv.token : undefined,
158
- tokenEnv: typeof argv.tokenEnv === 'string' ? argv.tokenEnv : undefined,
159
- });
160
-
161
- // 5. CLI options + conf — format-level parsing only. Namespace
162
- // allow-listing for conf happens later in resolveEffectiveConf;
163
- // options validation against the recipe's declared `allowed` set
164
- // happens at resolveEffectiveOptions time.
165
- // Both can throw — that's the contract: buildContext surfaces
166
- // input-level errors up front.
167
- const cliOptions = parseCliOptions(argv.option);
168
- const cliConf = parseCliConf(argv.conf);
169
-
170
- // 6. Flags.
171
- const flags = normaliseFlags(argv);
172
-
173
- // 7. JSON mode — side effect that must happen before the command
174
- // starts logging. Flipping the logger here means every command
175
- // that constructs a ctx gets JSON mode right automatically.
176
- if (flags.format === 'json') log.setJsonMode(true);
177
-
178
- // 8. Assemble.
179
- const ctx = {
180
- rootDir,
181
- manifestPath,
182
- cwd: process.cwd(),
183
- profiles,
184
- config,
185
- auth: Object.freeze({
186
- cli: authCli,
187
- for: (sourceEntry) => resolveSourceAuth(authCli, sourceEntry ?? null),
188
- }),
189
- cliOptions,
190
- cliConf,
191
- flags,
192
- argv,
193
- };
194
-
195
- // 9. Lazy manifest getter. Most commands need it; `show` does not.
196
- // Defining as a getter means:
197
- // - show pays nothing.
198
- // - other commands pay once, cached.
199
- // - an invalid manifest surfaces at first use, not at ctx
200
- // construction — so `show` never trips over it.
201
- let manifestCache = null;
202
- let manifestLoaded = false;
203
- Object.defineProperty(ctx, 'manifest', {
204
- configurable: false,
205
- enumerable: true,
206
- get() {
207
- if (!manifestLoaded) {
208
- if (!fs.existsSync(manifestPath)) {
209
- throw new Error(`Manifest not found: ${manifestPath}`);
210
- }
211
- manifestCache = readManifest(manifestPath);
212
- manifestLoaded = true;
213
- }
214
- return manifestCache;
215
- },
216
- });
217
-
218
- return ctx;
219
- }
220
-
221
- module.exports = {
222
- buildContext,
223
- // Exported for unit tests — kept out of the default import surface.
224
- _helpers: {
225
- resolveProfileName,
226
- normaliseFlags,
227
- defaultPlatform,
228
- resolveProfiles,
229
- },
230
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * CommandContext — the shared per-invocation state every command consumes.
5
+ *
6
+ * Commands used to each re-read the manifest, re-resolve the profile name,
7
+ * re-read config, re-resolve auth, and each toggle JSON mode on the logger.
8
+ * The divergence was cheap at 11 commands; it starts costing real bugs
9
+ * once F3 (`tool_requires`) and F4 (build/host profile split) double the
10
+ * flag surface. See [claude/PLAN-COMMAND-CONTEXT.md](../claude/PLAN-COMMAND-CONTEXT.md).
11
+ *
12
+ * `buildContext(argv)` runs once at the top of each command. It:
13
+ * - resolves filesystem anchors (rootDir, manifestPath)
14
+ * - reads config once
15
+ * - resolves the profile-name fallback chain + loads the profile
16
+ * - parses CLI options + conf (format-level only; namespace allow-
17
+ * listing happens later in the sinks that need it)
18
+ * - sets JSON mode on the logger if --format=json
19
+ * - exposes an auth helper pre-curried with CLI --token / --token-env
20
+ * - exposes a lazy manifest getter (show never reads the manifest)
21
+ *
22
+ * `buildContext` does NOT do source filtering — each command has subtly
23
+ * different CLI shape there (publish takes name-or-URL, install/show
24
+ * take URL arrays, pin-resolution only matters for install). That
25
+ * stays per-command until a second feature shows the same duplication.
26
+ *
27
+ * Errors: `buildContext` THROWS on all input-validation failures. Each
28
+ * command wraps the call in one try/catch that does `log.error + exit`.
29
+ * This keeps buildContext unit-testable; behaviour from the user's
30
+ * point of view is unchanged.
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const { readConfig } = require('./config');
37
+ const { loadProfile } = require('./profile');
38
+ const { readManifest } = require('./manifest');
39
+ const { resolveSourceAuth } = require('./auth');
40
+ const { parseCliOptions } = require('./options');
41
+ const { parseCliConf } = require('./conf');
42
+ const log = require('./logger');
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Internal helpers
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Resolve the active profile name per the long-standing precedence:
50
+ * --profile > config.defaultProfile > 'default'
51
+ */
52
+ function resolveProfileName(argv, config) {
53
+ if (typeof argv.profile === 'string' && argv.profile.length > 0) {
54
+ return argv.profile;
55
+ }
56
+ if (typeof config.defaultProfile === 'string' && config.defaultProfile.length > 0) {
57
+ return config.defaultProfile;
58
+ }
59
+ return 'default';
60
+ }
61
+
62
+ /**
63
+ * Normalise CLI flags into the stable `ctx.flags` shape. Centralising
64
+ * defaults here means individual commands don't each re-apply
65
+ * `?? 'never'` / `?? 'Release'` / etc.
66
+ *
67
+ * Flags that are command-specific (argv.preset, argv.pkg, argv.clean,
68
+ * argv.verbose, argv.generator, argv.install, argv.installPrefix,
69
+ * argv.uploadSource, argv.autoInstall, argv.noGpgSign, ...) stay on
70
+ * argv and commands read them there. `ctx.argv` is the escape hatch.
71
+ */
72
+ function normaliseFlags(argv) {
73
+ return {
74
+ format: argv.format === 'json' ? 'json' : 'text',
75
+ dryRun: argv.dryRun === true,
76
+ build: argv.build ?? 'never',
77
+ uploadBuilt: argv.uploadBuilt === true,
78
+ force: argv.force === true,
79
+ platform: argv.platform ?? defaultPlatform(),
80
+ timeout: typeof argv.timeout === 'number' ? argv.timeout : 300,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Default platform string when --platform is absent. Mirrors what
86
+ * bin/wyvrnpm.js passes when yargs applies its own default — keeping
87
+ * it here makes context.js testable without exercising the CLI layer.
88
+ */
89
+ function defaultPlatform() {
90
+ if (process.platform === 'win32') {
91
+ return process.arch === 'ia32' ? 'win_x86' : 'win_x64';
92
+ }
93
+ if (process.platform === 'darwin') return 'macos';
94
+ return 'linux';
95
+ }
96
+
97
+ /**
98
+ * Produce a frozen `{ host, build }` profile pair. Today both point at
99
+ * the same resolved object — when F4 (build/host profile split) lands,
100
+ * only this function changes; every command reading `ctx.profiles.host`
101
+ * or `ctx.profiles.build` continues to work.
102
+ *
103
+ * The two sub-objects share a reference today. A regression test in
104
+ * tests/context.test.js asserts `ctx.profiles.host === ctx.profiles.build`
105
+ * — that test is expected to be removed (not fixed) when F4 makes them
106
+ * legitimately distinct.
107
+ */
108
+ function resolveProfiles(argv, config) {
109
+ const name = resolveProfileName(argv, config);
110
+ const { profile, fromFile } = loadProfile(name);
111
+ const entry = Object.freeze({ name, profile, fromFile });
112
+ return Object.freeze({ host: entry, build: entry });
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // buildContext
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Build the per-invocation CommandContext.
121
+ *
122
+ * @param {object} argv - yargs-parsed argument map. Fields read:
123
+ * root, manifest, profile, format, dryRun, build, uploadBuilt,
124
+ * force, platform, timeout, token, tokenEnv, option, conf.
125
+ * Command-specific fields pass through on ctx.argv unchanged.
126
+ *
127
+ * @returns {CommandContext}
128
+ *
129
+ * @throws on:
130
+ * - parseCliOptions: malformed `-o pkg:name=value`
131
+ * - parseCliConf: malformed `--conf KEY=VALUE`
132
+ * - loadProfile: malformed profile JSON, cycle in `extends`, etc.
133
+ *
134
+ * Does NOT throw on:
135
+ * - missing manifest (lazy — throws at first ctx.manifest access)
136
+ * - missing config (readConfig returns empty defaults)
137
+ * - missing profile (loadProfile falls back to detectProfile())
138
+ * - missing --token-env value (resolveSourceAuth throws at ctx.auth.for time)
139
+ */
140
+ function buildContext(argv = {}) {
141
+ // 1. Filesystem anchors — pure path math, no I/O.
142
+ const rootDir = path.resolve(argv.root ?? '.');
143
+ const manifestPath = path.resolve(
144
+ argv.manifest ?? path.join(rootDir, 'wyvrn.json'),
145
+ );
146
+
147
+ // 2. Config — sync, small file, every command needs at least .defaultProfile.
148
+ const config = readConfig();
149
+
150
+ // 3. Profile (F4-ready shape).
151
+ const profiles = resolveProfiles(argv, config);
152
+
153
+ // 4. CLI auth, kept as the raw pair. Actual token lookup happens
154
+ // through ctx.auth.for(sourceEntry), which runs resolveSourceAuth
155
+ // each time so --token-env misses surface at use (same as today).
156
+ const authCli = Object.freeze({
157
+ token: typeof argv.token === 'string' ? argv.token : undefined,
158
+ tokenEnv: typeof argv.tokenEnv === 'string' ? argv.tokenEnv : undefined,
159
+ });
160
+
161
+ // 5. CLI options + conf — format-level parsing only. Namespace
162
+ // allow-listing for conf happens later in resolveEffectiveConf;
163
+ // options validation against the recipe's declared `allowed` set
164
+ // happens at resolveEffectiveOptions time.
165
+ // Both can throw — that's the contract: buildContext surfaces
166
+ // input-level errors up front.
167
+ const cliOptions = parseCliOptions(argv.option);
168
+ const cliConf = parseCliConf(argv.conf);
169
+
170
+ // 6. Flags.
171
+ const flags = normaliseFlags(argv);
172
+
173
+ // 7. JSON mode — side effect that must happen before the command
174
+ // starts logging. Flipping the logger here means every command
175
+ // that constructs a ctx gets JSON mode right automatically.
176
+ if (flags.format === 'json') log.setJsonMode(true);
177
+
178
+ // 8. Assemble.
179
+ const ctx = {
180
+ rootDir,
181
+ manifestPath,
182
+ cwd: process.cwd(),
183
+ profiles,
184
+ config,
185
+ auth: Object.freeze({
186
+ cli: authCli,
187
+ for: (sourceEntry) => resolveSourceAuth(authCli, sourceEntry ?? null),
188
+ }),
189
+ cliOptions,
190
+ cliConf,
191
+ flags,
192
+ argv,
193
+ };
194
+
195
+ // 9. Lazy manifest getter. Most commands need it; `show` does not.
196
+ // Defining as a getter means:
197
+ // - show pays nothing.
198
+ // - other commands pay once, cached.
199
+ // - an invalid manifest surfaces at first use, not at ctx
200
+ // construction — so `show` never trips over it.
201
+ let manifestCache = null;
202
+ let manifestLoaded = false;
203
+ Object.defineProperty(ctx, 'manifest', {
204
+ configurable: false,
205
+ enumerable: true,
206
+ get() {
207
+ if (!manifestLoaded) {
208
+ if (!fs.existsSync(manifestPath)) {
209
+ throw new Error(`Manifest not found: ${manifestPath}`);
210
+ }
211
+ manifestCache = readManifest(manifestPath);
212
+ manifestLoaded = true;
213
+ }
214
+ return manifestCache;
215
+ },
216
+ });
217
+
218
+ return ctx;
219
+ }
220
+
221
+ module.exports = {
222
+ buildContext,
223
+ // Exported for unit tests — kept out of the default import surface.
224
+ _helpers: {
225
+ resolveProfileName,
226
+ normaliseFlags,
227
+ defaultPlatform,
228
+ resolveProfiles,
229
+ },
230
+ };