wyvrnpm 2.11.0 → 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 -1905
  2. package/bin/{wyvrn.js → wyvrnpm.js} +31 -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 -187
  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,265 +1,265 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const os = require('os');
5
- const path = require('path');
6
-
7
- const { getProvider } = require('./providers');
8
- const { defaultCompatBlock } = require('./compat');
9
- const { getBuildPaths } = require('./build/cache');
10
- const { resolveSourceAuth } = require('./auth');
11
- const log = require('./logger');
12
-
13
- const WYVRNPM_VERSION = require('../package.json').version;
14
-
15
- const UPLOAD_SIDECAR = '.uploaded-to.json';
16
-
17
- /**
18
- * Record a successful upload in a sidecar file alongside the build-cache
19
- * entry. Append-only so repeated uploads to different destinations all
20
- * leave a trail. Purely informational — nothing in the install/build flow
21
- * branches on these files. Wiped by `wyvrnpm clean --uploaded-built`.
22
- *
23
- * @param {{ name: string, version: string, profileHash: string, source: string, contentSha256: string }} args
24
- */
25
- function recordUploadSidecar({ name, version, profileHash, source, contentSha256 }) {
26
- try {
27
- const paths = getBuildPaths({ name, version, profileHash });
28
- if (!fs.existsSync(paths.root)) return;
29
- const sidecarPath = path.join(paths.root, UPLOAD_SIDECAR);
30
- let doc = { uploads: [] };
31
- if (fs.existsSync(sidecarPath)) {
32
- try {
33
- const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
34
- if (Array.isArray(raw?.uploads)) doc = raw;
35
- } catch { /* corrupt sidecar — overwrite with fresh doc */ }
36
- }
37
- doc.uploads.push({
38
- uploadedAt: new Date().toISOString(),
39
- source,
40
- profileHash,
41
- contentSha256,
42
- });
43
- fs.writeFileSync(sidecarPath, JSON.stringify(doc, null, 2) + '\n', 'utf8');
44
- } catch {
45
- // Sidecar bookkeeping is best-effort — never break an install over it.
46
- }
47
- }
48
-
49
- /**
50
- * Upload a freshly source-built artefact back to the v2 registry under the
51
- * consumer's profileHash, so subsequent consumers with the same profile get
52
- * a direct download instead of re-cloning and re-compiling.
53
- *
54
- * See PLAN-UPLOAD-BUILT.md for the full design. Key properties:
55
- *
56
- * - Opt-in. Only called when the user passes `--upload-built` at install.
57
- * - Skips silently if the registry already has this (name, version,
58
- * profileHash) — the common case once the first teammate has uploaded.
59
- * - Never overwrites. A consumer with write credentials cannot clobber an
60
- * existing artefact; that still requires `publish --force` from the
61
- * source repo.
62
- * - Leaves `latest.json` and the `latest` pointer in `versions.json`
63
- * alone. A consumer upload is a new profileHash of an existing version,
64
- * not a new version release. Honoured via `updateLatest: false`.
65
- * - Records provenance in the uploaded manifest's `uploadedBy` block.
66
- * Absence of this block implies author-publish by convention.
67
- *
68
- * @param {object} args
69
- * @param {string} args.name
70
- * @param {string} args.version
71
- * @param {import('./profile').WyvrnProfile} args.profile
72
- * @param {string} args.profileHash
73
- * @param {string} args.artefactPath Absolute path to the built zip in %LOCALAPPDATA%\wyvrnpm\bc\.
74
- * @param {string} args.clonedManifestPath Absolute path to the cloned-source wyvrn.json.
75
- * @param {string} args.contentSha256 SHA256 of the built zip.
76
- * @param {string} args.gitRepo Source repo URL recorded at publish.
77
- * @param {string} args.gitSha Source commit recorded at publish.
78
- * @param {string} args.uploadSource Destination URL (resolved from --upload-source or config).
79
- * @param {object} [args.uploadAuth] { awsProfile?, token? }
80
- * @param {Record<string, any>|null} [args.options] Effective options for this build (fed through to buildSettings.options).
81
- * @param {string} [args.toolchainHash] Optional SHA of the generated wyvrn_toolchain.cmake.
82
- * @returns {Promise<{ uploaded: boolean, reason: string }>}
83
- */
84
- async function uploadSourceBuiltArtefact(args) {
85
- const {
86
- name, version, profile, profileHash,
87
- artefactPath, clonedManifestPath,
88
- contentSha256, gitRepo, gitSha,
89
- uploadSource, uploadAuth = {},
90
- options = null,
91
- toolchainHash,
92
- } = args;
93
-
94
- if (!uploadSource) {
95
- return { uploaded: false, reason: 'no upload source resolved' };
96
- }
97
- if (!fs.existsSync(artefactPath)) {
98
- return { uploaded: false, reason: `artefact zip missing at ${artefactPath}` };
99
- }
100
- if (!fs.existsSync(clonedManifestPath)) {
101
- // Cache-hit path on a previously-trimmed cache, or the source dir was
102
- // manually deleted. We have the binary but not the manifest we would
103
- // have uploaded alongside it. Surface it — nothing silent.
104
- return { uploaded: false, reason: `cloned manifest missing at ${clonedManifestPath}` };
105
- }
106
-
107
- let provider;
108
- try {
109
- provider = getProvider(uploadSource);
110
- } catch (err) {
111
- return { uploaded: false, reason: `no provider for ${uploadSource}: ${err.message}` };
112
- }
113
-
114
- const { awsProfile, token } = uploadAuth;
115
-
116
- // ── Skip-if-exists ────────────────────────────────────────────────────────
117
- const exists = await provider.v2Exists({
118
- source: uploadSource, name, version, profileHash, awsProfile, token,
119
- });
120
- if (exists) {
121
- log.info(`upload-built: ${name}@${version} [${profileHash}] already on ${uploadSource} — skipping`);
122
- return { uploaded: false, reason: 'already exists on registry' };
123
- }
124
-
125
- // ── Prepare manifest: cloned wyvrn.json + compat block + uploadedBy ──────
126
- const rawManifest = JSON.parse(fs.readFileSync(clonedManifestPath, 'utf8'));
127
- const manifestForUpload = {
128
- ...rawManifest,
129
- compatibility: rawManifest.compatibility ?? defaultCompatBlock(),
130
- uploadedBy: {
131
- kind: 'source-build',
132
- profileHash,
133
- builtFromGit: gitRepo ?? null,
134
- builtFromSha: gitSha ?? null,
135
- ...(toolchainHash ? { toolchainHash } : {}),
136
- uploadedAt: new Date().toISOString(),
137
- wyvrnpmVersion: WYVRNPM_VERSION,
138
- },
139
- };
140
-
141
- const tmpManifestPath = path.join(
142
- os.tmpdir(),
143
- `wyvrnpm-upload-${name}-${version}-${profileHash}-${Date.now()}.json`,
144
- );
145
- fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForUpload, null, 2), 'utf8');
146
-
147
- try {
148
- log.info(`upload-built: ${name}@${version} [${profileHash}] → ${uploadSource}`);
149
- await provider.v2Publish(
150
- { manifest: tmpManifestPath, zip: artefactPath },
151
- {
152
- source: uploadSource,
153
- name, version, profileHash,
154
- buildSettings: options ? { ...profile, options } : profile,
155
- contentSha256,
156
- gitSha: gitSha ?? null,
157
- gitRepo: gitRepo ?? null,
158
- // Consumer uploads must not reshape the "latest" pointer — see
159
- // PLAN-UPLOAD-BUILT §3.3.
160
- updateLatest: false,
161
- uploadedBy: manifestForUpload.uploadedBy,
162
- awsProfile,
163
- token,
164
- },
165
- );
166
- log.success(`upload-built: ${name}@${version} [${profileHash}] uploaded`);
167
- recordUploadSidecar({ name, version, profileHash, source: uploadSource, contentSha256 });
168
- return { uploaded: true, reason: 'ok' };
169
- } catch (err) {
170
- // The caller wraps us in try/catch too, but returning {uploaded:false}
171
- // here keeps the happy path type-stable.
172
- return { uploaded: false, reason: `upload failed: ${err.message}` };
173
- } finally {
174
- try { fs.unlinkSync(tmpManifestPath); } catch { /* ignore */ }
175
- }
176
- }
177
-
178
- /**
179
- * Resolve `--upload-source` (URL or configured-publish-source name) + auth
180
- * into an object the orchestrator understands. Mirrors the resolution
181
- * pattern in src/commands/publish.js so the two flows behave identically.
182
- *
183
- * Returns null if no destination can be resolved — caller decides whether
184
- * that is fatal (it is, if --upload-built was passed) or just skip-worthy.
185
- *
186
- * @param {object} argv
187
- * @param {string|undefined} argv.uploadSource
188
- * @param {string|undefined} argv.awsProfile
189
- * @param {string|undefined} argv.token
190
- * @param {{ publishSources: Array<{name:string, url:string, profile?:string, token?:string}> }} config
191
- * @returns {{ uploadSource: string, uploadAuth: { awsProfile?: string, token?: string } } | null}
192
- */
193
- function resolveUploadDestination(argv, config) {
194
- let src = argv.uploadSource;
195
- let awsProfile = argv.awsProfile;
196
- let token = argv.token;
197
- let sourceEntry = null;
198
-
199
- const publishSources = config.publishSources ?? [];
200
-
201
- if (src) {
202
- // Check if it matches a configured publish source by name.
203
- const named = publishSources.find((s) => s.name === src);
204
- if (named) {
205
- src = named.url;
206
- awsProfile = awsProfile ?? named.profile;
207
- sourceEntry = named;
208
- log.info(`upload-built: using publish source "${named.name}" from config`);
209
- }
210
- } else if (publishSources.length > 0) {
211
- const first = publishSources[0];
212
- src = first.url;
213
- awsProfile = awsProfile ?? first.profile;
214
- sourceEntry = first;
215
- log.info(`upload-built: using first configured publish source "${first.name}"`);
216
- }
217
-
218
- // Fall back to the matched source entry's own token / tokenEnv only if
219
- // the caller didn't pass one already (argv.token wins). resolveSourceAuth
220
- // handles the literal-vs-env-backed precedence per side.
221
- if (token === undefined || token === null) {
222
- token = resolveSourceAuth({}, sourceEntry).token;
223
- }
224
-
225
- if (!src) return null;
226
- return { uploadSource: src, uploadAuth: { awsProfile, token } };
227
- }
228
-
229
- /**
230
- * Create an empty accumulator for aggregated upload outcomes. Pass the
231
- * returned object as `options.uploadStats` to `downloadDependencies`, then
232
- * feed it to `formatUploadSummary()` at the end of the install.
233
- *
234
- * @returns {{ uploaded: Array<object>, skipped: Array<object>, failed: Array<object> }}
235
- */
236
- function createUploadStats() {
237
- return { uploaded: [], skipped: [], failed: [] };
238
- }
239
-
240
- /**
241
- * Produce the one-line aggregated summary shown at the end of an install
242
- * when `--upload-built` was passed. Returns null when nothing was attempted
243
- * (so the caller can skip printing).
244
- *
245
- * @param {{ uploaded: Array, skipped: Array, failed: Array }} stats
246
- * @returns {string|null}
247
- */
248
- function formatUploadSummary(stats) {
249
- if (!stats) return null;
250
- const total = stats.uploaded.length + stats.skipped.length + stats.failed.length;
251
- if (total === 0) return null;
252
- const n = (arr, singular) => `${arr.length} ${singular}${arr.length === 1 ? '' : 's'}`;
253
- return (
254
- `upload-built summary: ${n(stats.uploaded, 'artefact')} uploaded, ` +
255
- `${stats.skipped.length} skipped (already existed), ` +
256
- `${stats.failed.length} failed`
257
- );
258
- }
259
-
260
- module.exports = {
261
- uploadSourceBuiltArtefact,
262
- resolveUploadDestination,
263
- createUploadStats,
264
- formatUploadSummary,
265
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const { getProvider } = require('./providers');
8
+ const { defaultCompatBlock } = require('./compat');
9
+ const { getBuildPaths } = require('./build/cache');
10
+ const { resolveSourceAuth } = require('./auth');
11
+ const log = require('./logger');
12
+
13
+ const WYVRNPM_VERSION = require('../package.json').version;
14
+
15
+ const UPLOAD_SIDECAR = '.uploaded-to.json';
16
+
17
+ /**
18
+ * Record a successful upload in a sidecar file alongside the build-cache
19
+ * entry. Append-only so repeated uploads to different destinations all
20
+ * leave a trail. Purely informational — nothing in the install/build flow
21
+ * branches on these files. Wiped by `wyvrnpm clean --uploaded-built`.
22
+ *
23
+ * @param {{ name: string, version: string, profileHash: string, source: string, contentSha256: string }} args
24
+ */
25
+ function recordUploadSidecar({ name, version, profileHash, source, contentSha256 }) {
26
+ try {
27
+ const paths = getBuildPaths({ name, version, profileHash });
28
+ if (!fs.existsSync(paths.root)) return;
29
+ const sidecarPath = path.join(paths.root, UPLOAD_SIDECAR);
30
+ let doc = { uploads: [] };
31
+ if (fs.existsSync(sidecarPath)) {
32
+ try {
33
+ const raw = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
34
+ if (Array.isArray(raw?.uploads)) doc = raw;
35
+ } catch { /* corrupt sidecar — overwrite with fresh doc */ }
36
+ }
37
+ doc.uploads.push({
38
+ uploadedAt: new Date().toISOString(),
39
+ source,
40
+ profileHash,
41
+ contentSha256,
42
+ });
43
+ fs.writeFileSync(sidecarPath, JSON.stringify(doc, null, 2) + '\n', 'utf8');
44
+ } catch {
45
+ // Sidecar bookkeeping is best-effort — never break an install over it.
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Upload a freshly source-built artefact back to the v2 registry under the
51
+ * consumer's profileHash, so subsequent consumers with the same profile get
52
+ * a direct download instead of re-cloning and re-compiling.
53
+ *
54
+ * See PLAN-UPLOAD-BUILT.md for the full design. Key properties:
55
+ *
56
+ * - Opt-in. Only called when the user passes `--upload-built` at install.
57
+ * - Skips silently if the registry already has this (name, version,
58
+ * profileHash) — the common case once the first teammate has uploaded.
59
+ * - Never overwrites. A consumer with write credentials cannot clobber an
60
+ * existing artefact; that still requires `publish --force` from the
61
+ * source repo.
62
+ * - Leaves `latest.json` and the `latest` pointer in `versions.json`
63
+ * alone. A consumer upload is a new profileHash of an existing version,
64
+ * not a new version release. Honoured via `updateLatest: false`.
65
+ * - Records provenance in the uploaded manifest's `uploadedBy` block.
66
+ * Absence of this block implies author-publish by convention.
67
+ *
68
+ * @param {object} args
69
+ * @param {string} args.name
70
+ * @param {string} args.version
71
+ * @param {import('./profile').WyvrnProfile} args.profile
72
+ * @param {string} args.profileHash
73
+ * @param {string} args.artefactPath Absolute path to the built zip in %LOCALAPPDATA%\wyvrnpm\bc\.
74
+ * @param {string} args.clonedManifestPath Absolute path to the cloned-source wyvrn.json.
75
+ * @param {string} args.contentSha256 SHA256 of the built zip.
76
+ * @param {string} args.gitRepo Source repo URL recorded at publish.
77
+ * @param {string} args.gitSha Source commit recorded at publish.
78
+ * @param {string} args.uploadSource Destination URL (resolved from --upload-source or config).
79
+ * @param {object} [args.uploadAuth] { awsProfile?, token? }
80
+ * @param {Record<string, any>|null} [args.options] Effective options for this build (fed through to buildSettings.options).
81
+ * @param {string} [args.toolchainHash] Optional SHA of the generated wyvrn_toolchain.cmake.
82
+ * @returns {Promise<{ uploaded: boolean, reason: string }>}
83
+ */
84
+ async function uploadSourceBuiltArtefact(args) {
85
+ const {
86
+ name, version, profile, profileHash,
87
+ artefactPath, clonedManifestPath,
88
+ contentSha256, gitRepo, gitSha,
89
+ uploadSource, uploadAuth = {},
90
+ options = null,
91
+ toolchainHash,
92
+ } = args;
93
+
94
+ if (!uploadSource) {
95
+ return { uploaded: false, reason: 'no upload source resolved' };
96
+ }
97
+ if (!fs.existsSync(artefactPath)) {
98
+ return { uploaded: false, reason: `artefact zip missing at ${artefactPath}` };
99
+ }
100
+ if (!fs.existsSync(clonedManifestPath)) {
101
+ // Cache-hit path on a previously-trimmed cache, or the source dir was
102
+ // manually deleted. We have the binary but not the manifest we would
103
+ // have uploaded alongside it. Surface it — nothing silent.
104
+ return { uploaded: false, reason: `cloned manifest missing at ${clonedManifestPath}` };
105
+ }
106
+
107
+ let provider;
108
+ try {
109
+ provider = getProvider(uploadSource);
110
+ } catch (err) {
111
+ return { uploaded: false, reason: `no provider for ${uploadSource}: ${err.message}` };
112
+ }
113
+
114
+ const { awsProfile, token } = uploadAuth;
115
+
116
+ // ── Skip-if-exists ────────────────────────────────────────────────────────
117
+ const exists = await provider.v2Exists({
118
+ source: uploadSource, name, version, profileHash, awsProfile, token,
119
+ });
120
+ if (exists) {
121
+ log.info(`upload-built: ${name}@${version} [${profileHash}] already on ${uploadSource} — skipping`);
122
+ return { uploaded: false, reason: 'already exists on registry' };
123
+ }
124
+
125
+ // ── Prepare manifest: cloned wyvrn.json + compat block + uploadedBy ──────
126
+ const rawManifest = JSON.parse(fs.readFileSync(clonedManifestPath, 'utf8'));
127
+ const manifestForUpload = {
128
+ ...rawManifest,
129
+ compatibility: rawManifest.compatibility ?? defaultCompatBlock(),
130
+ uploadedBy: {
131
+ kind: 'source-build',
132
+ profileHash,
133
+ builtFromGit: gitRepo ?? null,
134
+ builtFromSha: gitSha ?? null,
135
+ ...(toolchainHash ? { toolchainHash } : {}),
136
+ uploadedAt: new Date().toISOString(),
137
+ wyvrnpmVersion: WYVRNPM_VERSION,
138
+ },
139
+ };
140
+
141
+ const tmpManifestPath = path.join(
142
+ os.tmpdir(),
143
+ `wyvrnpm-upload-${name}-${version}-${profileHash}-${Date.now()}.json`,
144
+ );
145
+ fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForUpload, null, 2), 'utf8');
146
+
147
+ try {
148
+ log.info(`upload-built: ${name}@${version} [${profileHash}] → ${uploadSource}`);
149
+ await provider.v2Publish(
150
+ { manifest: tmpManifestPath, zip: artefactPath },
151
+ {
152
+ source: uploadSource,
153
+ name, version, profileHash,
154
+ buildSettings: options ? { ...profile, options } : profile,
155
+ contentSha256,
156
+ gitSha: gitSha ?? null,
157
+ gitRepo: gitRepo ?? null,
158
+ // Consumer uploads must not reshape the "latest" pointer — see
159
+ // PLAN-UPLOAD-BUILT §3.3.
160
+ updateLatest: false,
161
+ uploadedBy: manifestForUpload.uploadedBy,
162
+ awsProfile,
163
+ token,
164
+ },
165
+ );
166
+ log.success(`upload-built: ${name}@${version} [${profileHash}] uploaded`);
167
+ recordUploadSidecar({ name, version, profileHash, source: uploadSource, contentSha256 });
168
+ return { uploaded: true, reason: 'ok' };
169
+ } catch (err) {
170
+ // The caller wraps us in try/catch too, but returning {uploaded:false}
171
+ // here keeps the happy path type-stable.
172
+ return { uploaded: false, reason: `upload failed: ${err.message}` };
173
+ } finally {
174
+ try { fs.unlinkSync(tmpManifestPath); } catch { /* ignore */ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Resolve `--upload-source` (URL or configured-publish-source name) + auth
180
+ * into an object the orchestrator understands. Mirrors the resolution
181
+ * pattern in src/commands/publish.js so the two flows behave identically.
182
+ *
183
+ * Returns null if no destination can be resolved — caller decides whether
184
+ * that is fatal (it is, if --upload-built was passed) or just skip-worthy.
185
+ *
186
+ * @param {object} argv
187
+ * @param {string|undefined} argv.uploadSource
188
+ * @param {string|undefined} argv.awsProfile
189
+ * @param {string|undefined} argv.token
190
+ * @param {{ publishSources: Array<{name:string, url:string, profile?:string, token?:string}> }} config
191
+ * @returns {{ uploadSource: string, uploadAuth: { awsProfile?: string, token?: string } } | null}
192
+ */
193
+ function resolveUploadDestination(argv, config) {
194
+ let src = argv.uploadSource;
195
+ let awsProfile = argv.awsProfile;
196
+ let token = argv.token;
197
+ let sourceEntry = null;
198
+
199
+ const publishSources = config.publishSources ?? [];
200
+
201
+ if (src) {
202
+ // Check if it matches a configured publish source by name.
203
+ const named = publishSources.find((s) => s.name === src);
204
+ if (named) {
205
+ src = named.url;
206
+ awsProfile = awsProfile ?? named.profile;
207
+ sourceEntry = named;
208
+ log.info(`upload-built: using publish source "${named.name}" from config`);
209
+ }
210
+ } else if (publishSources.length > 0) {
211
+ const first = publishSources[0];
212
+ src = first.url;
213
+ awsProfile = awsProfile ?? first.profile;
214
+ sourceEntry = first;
215
+ log.info(`upload-built: using first configured publish source "${first.name}"`);
216
+ }
217
+
218
+ // Fall back to the matched source entry's own token / tokenEnv only if
219
+ // the caller didn't pass one already (argv.token wins). resolveSourceAuth
220
+ // handles the literal-vs-env-backed precedence per side.
221
+ if (token === undefined || token === null) {
222
+ token = resolveSourceAuth({}, sourceEntry).token;
223
+ }
224
+
225
+ if (!src) return null;
226
+ return { uploadSource: src, uploadAuth: { awsProfile, token } };
227
+ }
228
+
229
+ /**
230
+ * Create an empty accumulator for aggregated upload outcomes. Pass the
231
+ * returned object as `options.uploadStats` to `downloadDependencies`, then
232
+ * feed it to `formatUploadSummary()` at the end of the install.
233
+ *
234
+ * @returns {{ uploaded: Array<object>, skipped: Array<object>, failed: Array<object> }}
235
+ */
236
+ function createUploadStats() {
237
+ return { uploaded: [], skipped: [], failed: [] };
238
+ }
239
+
240
+ /**
241
+ * Produce the one-line aggregated summary shown at the end of an install
242
+ * when `--upload-built` was passed. Returns null when nothing was attempted
243
+ * (so the caller can skip printing).
244
+ *
245
+ * @param {{ uploaded: Array, skipped: Array, failed: Array }} stats
246
+ * @returns {string|null}
247
+ */
248
+ function formatUploadSummary(stats) {
249
+ if (!stats) return null;
250
+ const total = stats.uploaded.length + stats.skipped.length + stats.failed.length;
251
+ if (total === 0) return null;
252
+ const n = (arr, singular) => `${arr.length} ${singular}${arr.length === 1 ? '' : 's'}`;
253
+ return (
254
+ `upload-built summary: ${n(stats.uploaded, 'artefact')} uploaded, ` +
255
+ `${stats.skipped.length} skipped (already existed), ` +
256
+ `${stats.failed.length} failed`
257
+ );
258
+ }
259
+
260
+ module.exports = {
261
+ uploadSourceBuiltArtefact,
262
+ resolveUploadDestination,
263
+ createUploadStats,
264
+ formatUploadSummary,
265
+ };