wyvrnpm 1.3.2 → 2.0.1

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/src/download.js CHANGED
@@ -1,63 +1,34 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
3
+ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { pipeline } = require('stream/promises');
6
6
  const { Readable } = require('stream');
7
7
  const StreamZip = require('node-stream-zip');
8
+
8
9
  const { isLink, getLinkTarget } = require('./link-utils');
10
+ const { sha256Of } = require('./profile');
11
+ const { getProvider } = require('./providers');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // v1 (legacy) download helpers
15
+ // ---------------------------------------------------------------------------
9
16
 
10
- /**
11
- * Downloads a single URL to a local file path, respecting the given timeout.
12
- *
13
- * @param {string} url - Remote URL to download.
14
- * @param {string} destPath - Local path to write the response body to.
15
- * @param {Function} httpClient - fetch-compatible function.
16
- * @param {number} timeoutMs - Abort timeout in milliseconds.
17
- * @returns {Promise<void>}
18
- * @throws {Error} On non-OK HTTP status or network / timeout error.
19
- */
20
17
  async function downloadFile(url, destPath, httpClient, timeoutMs) {
21
18
  const controller = new AbortController();
22
19
  const timer = setTimeout(() => controller.abort(), timeoutMs);
23
-
24
20
  let response;
25
21
  try {
26
22
  response = await httpClient(url, { signal: controller.signal });
27
23
  } finally {
28
24
  clearTimeout(timer);
29
25
  }
30
-
31
- if (!response.ok) {
32
- throw new Error(`HTTP ${response.status} for ${url}`);
33
- }
34
-
35
- // Stream the response body directly to disk — avoids loading large files into RAM.
26
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
36
27
  const fileStream = fs.createWriteStream(destPath);
37
28
  await pipeline(Readable.fromWeb(response.body), fileStream);
38
29
  }
39
30
 
40
- /**
41
- * Resolves which source URL contains a given package by probing each one in order.
42
- *
43
- * @param {string} name
44
- * @param {string} version
45
- * @param {string[]} packageSources
46
- * @param {string} platform
47
- * @param {Function} httpClient
48
- * @param {number} timeoutMs
49
- * @param {string} destZipPath - Where to save the downloaded zip.
50
- * @returns {Promise<boolean>} True if successfully downloaded, false otherwise.
51
- */
52
- async function tryDownloadFromSources(
53
- name,
54
- version,
55
- packageSources,
56
- platform,
57
- httpClient,
58
- timeoutMs,
59
- destZipPath,
60
- ) {
31
+ async function tryDownloadFromSources(name, version, packageSources, platform, httpClient, timeoutMs, destZipPath) {
61
32
  for (const source of packageSources) {
62
33
  const base = source.replace(/\/$/, '');
63
34
  const candidates = [
@@ -78,95 +49,277 @@ async function tryDownloadFromSources(
78
49
  return false;
79
50
  }
80
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // v2 download helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Try to download a v2 prebuilt binary for a package.
58
+ *
59
+ * Resolution order:
60
+ * 1. Exact profile hash match
61
+ * 2. Compatible build from versions.json (same OS + arch, any compiler)
62
+ * 3. Return null (caller should fall back to v1)
63
+ *
64
+ * @param {{
65
+ * name: string,
66
+ * version: string,
67
+ * profileHash: string,
68
+ * profile: import('./profile').WyvrnProfile,
69
+ * }} dep
70
+ * @param {string[]} packageSources
71
+ * @param {string} destZipPath
72
+ * @param {number} timeoutMs
73
+ * @param {object} authOptions - { awsProfile?, token? }
74
+ * @returns {Promise<{
75
+ * found: boolean,
76
+ * contentSha256: string|null,
77
+ * gitSha: string|null,
78
+ * gitRepo: string|null,
79
+ * profileHash: string,
80
+ * source: string, // which source URL was used
81
+ * }|null>} null = not found in v2
82
+ */
83
+ async function tryDownloadV2(dep, packageSources, destZipPath, timeoutMs, authOptions = {}) {
84
+ const { name, version, profileHash, profile } = dep;
85
+ const { awsProfile, token } = authOptions;
86
+
87
+ for (const source of packageSources) {
88
+ let provider;
89
+ try { provider = getProvider(source); } catch { continue; }
90
+
91
+ // 1) Exact profile hash
92
+ const exactMeta = await provider.v2GetMeta({ source, name, version, profileHash, awsProfile, token });
93
+ if (exactMeta) {
94
+ const ok = await provider.v2DownloadZip({ source, name, version, profileHash, awsProfile, token }, destZipPath, timeoutMs);
95
+ if (ok) {
96
+ return {
97
+ found: true,
98
+ contentSha256: exactMeta.contentSha256 ?? null,
99
+ gitSha: exactMeta.gitSha ?? null,
100
+ gitRepo: exactMeta.gitRepo ?? null,
101
+ profileHash,
102
+ source,
103
+ };
104
+ }
105
+ }
106
+
107
+ // 2) Fallback: scan versions.json for a compatible build (same OS + arch)
108
+ const idx = await provider.v2GetVersionsIndex({ source, name, awsProfile, token });
109
+ if (!idx?.versions?.[version]?.profiles) continue;
110
+
111
+ const versionEntry = idx.versions[version];
112
+ for (const [ph, buildEntry] of Object.entries(versionEntry.profiles)) {
113
+ // buildSettings is the copy of the profile stored for debugging at publish time
114
+ const bp = buildEntry.buildSettings ?? buildEntry.profile;
115
+ if (!bp) continue;
116
+ if (bp.os !== profile.os) continue;
117
+ if (bp.arch !== profile.arch) continue;
118
+ // Same OS + arch — accept as a compatible fallback
119
+ console.warn(
120
+ `[wyvrn] No exact profile match for ${name}@${version}; using compatible build [${ph}]` +
121
+ ` (${bp.compiler} ${bp['compiler.version']})`,
122
+ );
123
+ const ok = await provider.v2DownloadZip({ source, name, version, profileHash: ph, awsProfile, token }, destZipPath, timeoutMs);
124
+ if (ok) {
125
+ return {
126
+ found: true,
127
+ contentSha256: buildEntry.contentSha256 ?? null,
128
+ gitSha: versionEntry.source?.gitSha ?? null,
129
+ gitRepo: versionEntry.source?.gitRepo ?? null,
130
+ profileHash: ph,
131
+ source,
132
+ };
133
+ }
134
+ }
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Extraction helper
142
+ // ---------------------------------------------------------------------------
143
+
144
+ async function extractZip(destZipPath, extractDir) {
145
+ const zip = new StreamZip.async({ file: destZipPath });
146
+ await zip.extract(null, extractDir);
147
+ await zip.close();
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Main downloadDependencies (v2-aware)
152
+ // ---------------------------------------------------------------------------
153
+
81
154
  /**
82
- * Downloads and extracts all resolved dependencies that are not already present on disk.
155
+ * Downloads and extracts all resolved dependencies.
156
+ * Tries v2 first (profile-aware + SHA256 verification), falls back to v1.
83
157
  *
84
- * @param {Map<string, string>} resolvedDeps - Map of package name -> version string.
85
- * @param {string[]} packageSources - Ordered list of base URLs to try.
86
- * @param {string} platform - Target platform string (e.g. "win_x64").
87
- * @param {string} razerDir - Local directory used for extraction (e.g. "wyvrn_internal").
88
- * @param {Function} httpClient - fetch-compatible function.
89
- * @param {number} timeout - HTTP timeout in seconds.
90
- * @returns {Promise<void>}
158
+ * @param {Map<string, string | {version:string, profileHash:string|null, profile:import('./profile').WyvrnProfile|null}>} deps
159
+ * Either a plain `Map<name, version>` (legacy callers / tests) or the enriched map
160
+ * produced by install.js: `Map<name, { version, profileHash, profile }>`.
161
+ * @param {string[]} packageSources
162
+ * @param {string} platform - v1 legacy platform string
163
+ * @param {string} razerDir
164
+ * @param {Function} httpClient - fetch-compatible
165
+ * @param {number} timeout - seconds
166
+ * @param {object} [authOptions] - { awsProfile?, token? }
167
+ * @returns {Promise<Map<string, {version:string, profileHash?:string, contentSha256?:string, gitSha?:string, resolvedFrom:string}>>}
91
168
  */
92
- async function downloadDependencies(
93
- resolvedDeps,
94
- packageSources,
95
- platform,
96
- razerDir,
97
- httpClient,
98
- timeout,
99
- ) {
169
+ async function downloadDependencies(deps, packageSources, platform, razerDir, httpClient, timeout, authOptions = {}) {
100
170
  const timeoutMs = timeout * 1000;
171
+ const { awsProfile, token } = authOptions;
101
172
 
102
173
  if (!fs.existsSync(razerDir)) {
103
174
  fs.mkdirSync(razerDir, { recursive: true });
104
175
  }
105
176
 
106
- for (const [name, version] of resolvedDeps) {
107
- const extractDir = path.join(razerDir, name);
177
+ /** @type {Map<string, object>} */
178
+ const lockEntries = new Map();
179
+
180
+ for (const [name, depInfoOrVersion] of deps) {
181
+ // Normalise: accept both plain version string (legacy) and enriched object
182
+ const isLegacyEntry = typeof depInfoOrVersion === 'string';
183
+ const version = isLegacyEntry ? depInfoOrVersion : depInfoOrVersion.version;
184
+ const profileHash = isLegacyEntry ? null : (depInfoOrVersion.profileHash ?? null);
185
+ const profile = isLegacyEntry ? null : (depInfoOrVersion.profile ?? null);
186
+
187
+ const extractDir = path.join(razerDir, name);
108
188
  const versionFile = path.join(extractDir, '.wyvrn-version');
189
+ const metaFile = path.join(extractDir, '.wyvrn-meta.json');
109
190
 
110
- // Skip linked packages - don't overwrite symlinks/junctions
191
+ // Skip linked packages
111
192
  if (isLink(extractDir)) {
112
193
  const linkTarget = getLinkTarget(extractDir);
113
194
  console.log(`[wyvrn] Linked: ${name} → ${linkTarget} (skipping download)`);
195
+ lockEntries.set(name, { version, resolvedFrom: 'link' });
114
196
  continue;
115
197
  }
116
198
 
199
+ // Skip if already at correct version + profile hash
117
200
  if (fs.existsSync(extractDir)) {
118
201
  const installedVersion = fs.existsSync(versionFile)
119
202
  ? fs.readFileSync(versionFile, 'utf8').trim()
120
203
  : null;
121
- if (installedVersion === version) {
122
- console.log(`[wyvrn] Already present: ${name}@${version} — skipping`);
204
+ const installedMeta = fs.existsSync(metaFile)
205
+ ? JSON.parse(fs.readFileSync(metaFile, 'utf8'))
206
+ : null;
207
+
208
+ const sameVersion = installedVersion === version;
209
+ const sameProfile = !profileHash || installedMeta?.profileHash === profileHash;
210
+
211
+ if (sameVersion && sameProfile) {
212
+ console.log(`[wyvrn] Already present: ${name}@${version}${profileHash ? ` [${installedMeta?.profileHash}]` : ''} — skipping`);
213
+ lockEntries.set(name, {
214
+ version,
215
+ profileHash: installedMeta?.profileHash ?? null,
216
+ contentSha256: installedMeta?.contentSha256 ?? null,
217
+ gitSha: installedMeta?.gitSha ?? null,
218
+ resolvedFrom: installedMeta?.resolvedFrom ?? 'cached',
219
+ });
123
220
  continue;
124
221
  }
125
- console.log(`[wyvrn] Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
222
+ if (!sameVersion) {
223
+ console.log(`[wyvrn] Version changed: ${name} ${installedVersion} → ${version}, re-downloading`);
224
+ } else {
225
+ console.log(`[wyvrn] Profile changed for ${name}@${version}, re-downloading`);
226
+ }
126
227
  fs.rmSync(extractDir, { recursive: true, force: true });
127
228
  }
128
229
 
129
230
  console.log(`[wyvrn] Downloading: ${name}@${version}`);
130
-
131
231
  const destZipPath = path.join(razerDir, `${name}.zip`);
132
232
 
133
- const ok = await tryDownloadFromSources(
134
- name,
135
- version,
136
- packageSources,
137
- platform,
138
- httpClient,
139
- timeoutMs,
140
- destZipPath,
141
- );
142
-
143
- if (!ok) {
144
- console.warn(
145
- `[wyvrn] Warning: package ${name}@${version} was not found in any configured source`,
233
+ let downloadResult = null;
234
+ let resolvedFrom = 'v1';
235
+
236
+ // ── Try v2 first ────────────────────────────────────────────────────────
237
+ if (profileHash && profile) {
238
+ downloadResult = await tryDownloadV2(
239
+ { name, version, profileHash, profile },
240
+ packageSources,
241
+ destZipPath,
242
+ timeoutMs,
243
+ { awsProfile, token },
146
244
  );
147
- continue;
245
+ if (downloadResult) resolvedFrom = 'v2';
246
+ }
247
+
248
+ // ── Fall back to v1 ─────────────────────────────────────────────────────
249
+ if (!downloadResult) {
250
+ const ok = await tryDownloadFromSources(name, version, packageSources, platform, httpClient, timeoutMs, destZipPath);
251
+ if (!ok) {
252
+ console.warn(`[wyvrn] Warning: package ${name}@${version} was not found in any configured source`);
253
+ lockEntries.set(name, { version, resolvedFrom: 'not-found' });
254
+ continue;
255
+ }
256
+ }
257
+
258
+ // ── SHA256 integrity check (v2 only) ────────────────────────────────────
259
+ if (downloadResult?.contentSha256) {
260
+ const actual = sha256Of(destZipPath);
261
+ if (actual !== downloadResult.contentSha256) {
262
+ console.error(
263
+ `[wyvrn] Error: SHA256 mismatch for ${name}@${version}\n` +
264
+ ` expected: ${downloadResult.contentSha256}\n` +
265
+ ` actual: ${actual}`,
266
+ );
267
+ try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
268
+ lockEntries.set(name, { version, resolvedFrom: 'sha256-mismatch' });
269
+ continue;
270
+ }
271
+ console.log(`[wyvrn] SHA256 verified: ${actual.slice(0, 16)}...`);
148
272
  }
149
273
 
274
+ // ── Extract ─────────────────────────────────────────────────────────────
150
275
  try {
151
276
  fs.mkdirSync(extractDir, { recursive: true });
152
- const zip = new StreamZip.async({ file: destZipPath });
153
- await zip.extract(null, extractDir);
154
- await zip.close();
277
+ await extractZip(destZipPath, extractDir);
278
+
279
+ // Write .wyvrn-version (v1 compat)
155
280
  fs.writeFileSync(versionFile, version, 'utf8');
156
- console.log(`[wyvrn] Extracted: ${name}@${version} → ${extractDir}`);
281
+
282
+ // Write .wyvrn-meta.json (v2 extended metadata)
283
+ const meta = {
284
+ version,
285
+ resolvedFrom,
286
+ profileHash: downloadResult?.profileHash ?? null,
287
+ contentSha256: downloadResult?.contentSha256 ?? null,
288
+ gitSha: downloadResult?.gitSha ?? null,
289
+ gitRepo: downloadResult?.gitRepo ?? null,
290
+ installedAt: new Date().toISOString(),
291
+ };
292
+ fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n', 'utf8');
293
+
294
+ console.log(
295
+ `[wyvrn] Extracted: ${name}@${version}` +
296
+ (downloadResult?.profileHash ? ` [${downloadResult.profileHash}]` : '') +
297
+ ` (${resolvedFrom}) → ${extractDir}`,
298
+ );
299
+
300
+ if (downloadResult?.gitSha) {
301
+ console.log(`[wyvrn] Source commit : ${downloadResult.gitSha}`);
302
+ if (downloadResult?.gitRepo) {
303
+ console.log(`[wyvrn] Source repo : ${downloadResult.gitRepo}`);
304
+ }
305
+ }
306
+
307
+ lockEntries.set(name, {
308
+ version,
309
+ resolvedFrom,
310
+ profileHash: downloadResult?.profileHash ?? null,
311
+ contentSha256: downloadResult?.contentSha256 ?? null,
312
+ gitSha: downloadResult?.gitSha ?? null,
313
+ });
157
314
  } catch (err) {
158
315
  console.warn(`[wyvrn] Failed to extract ${name}@${version}: ${err.message}`);
316
+ lockEntries.set(name, { version, resolvedFrom: 'extract-failed' });
159
317
  } finally {
160
- // Always remove the zip, even if extraction failed.
161
- try {
162
- fs.unlinkSync(destZipPath);
163
- } catch {
164
- // Ignore cleanup errors.
165
- }
318
+ try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
166
319
  }
167
320
  }
321
+
322
+ return lockEntries;
168
323
  }
169
324
 
170
- module.exports = {
171
- downloadDependencies,
172
- };
325
+ module.exports = { downloadDependencies };
package/src/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  module.exports = {
4
- init: require('./commands/init'),
5
- install: require('./commands/install'),
6
- clean: require('./commands/clean'),
7
- publish: require('./commands/publish'),
4
+ init: require('./commands/init'),
5
+ install: require('./commands/install'),
6
+ clean: require('./commands/clean'),
7
+ publish: require('./commands/publish'),
8
8
  configure: require('./commands/configure'),
9
+ profile: require('./commands/profile'),
9
10
  };
package/src/link-utils.js CHANGED
@@ -1,95 +1,95 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
-
6
- /**
7
- * Creates a symlink/junction from linkPath pointing to targetPath.
8
- * Uses junctions on Windows (no admin rights required), symlinks on Unix.
9
- *
10
- * @param {string} targetPath - The actual directory to link to.
11
- * @param {string} linkPath - The path where the link will be created.
12
- */
13
- function createLink(targetPath, linkPath) {
14
- const absoluteTarget = path.resolve(targetPath);
15
- const absoluteLink = path.resolve(linkPath);
16
-
17
- // Ensure parent directory exists
18
- const parentDir = path.dirname(absoluteLink);
19
- if (!fs.existsSync(parentDir)) {
20
- fs.mkdirSync(parentDir, { recursive: true });
21
- }
22
-
23
- // Remove existing link/directory if present
24
- if (fs.existsSync(absoluteLink)) {
25
- const stats = fs.lstatSync(absoluteLink);
26
- if (stats.isSymbolicLink()) {
27
- fs.unlinkSync(absoluteLink);
28
- } else {
29
- fs.rmSync(absoluteLink, { recursive: true, force: true });
30
- }
31
- }
32
-
33
- // Use 'junction' on Windows (no admin required), 'dir' on Unix
34
- const linkType = process.platform === 'win32' ? 'junction' : 'dir';
35
- fs.symlinkSync(absoluteTarget, absoluteLink, linkType);
36
- }
37
-
38
- /**
39
- * Checks if a path is a symlink or junction.
40
- *
41
- * @param {string} linkPath - Path to check.
42
- * @returns {boolean} True if it's a symlink/junction.
43
- */
44
- function isLink(linkPath) {
45
- try {
46
- const stats = fs.lstatSync(linkPath);
47
- return stats.isSymbolicLink();
48
- } catch {
49
- return false;
50
- }
51
- }
52
-
53
- /**
54
- * Removes a symlink/junction without affecting the target directory.
55
- *
56
- * @param {string} linkPath - Path to the symlink/junction to remove.
57
- * @returns {boolean} True if removed, false if it didn't exist or wasn't a link.
58
- */
59
- function removeLink(linkPath) {
60
- try {
61
- const stats = fs.lstatSync(linkPath);
62
- if (stats.isSymbolicLink()) {
63
- fs.unlinkSync(linkPath);
64
- return true;
65
- }
66
- return false;
67
- } catch {
68
- return false;
69
- }
70
- }
71
-
72
- /**
73
- * Gets the target path of a symlink/junction.
74
- *
75
- * @param {string} linkPath - Path to the symlink/junction.
76
- * @returns {string|null} The target path, or null if not a link.
77
- */
78
- function getLinkTarget(linkPath) {
79
- try {
80
- const stats = fs.lstatSync(linkPath);
81
- if (stats.isSymbolicLink()) {
82
- return fs.readlinkSync(linkPath);
83
- }
84
- return null;
85
- } catch {
86
- return null;
87
- }
88
- }
89
-
90
- module.exports = {
91
- createLink,
92
- isLink,
93
- removeLink,
94
- getLinkTarget,
95
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Creates a symlink/junction from linkPath pointing to targetPath.
8
+ * Uses junctions on Windows (no admin rights required), symlinks on Unix.
9
+ *
10
+ * @param {string} targetPath - The actual directory to link to.
11
+ * @param {string} linkPath - The path where the link will be created.
12
+ */
13
+ function createLink(targetPath, linkPath) {
14
+ const absoluteTarget = path.resolve(targetPath);
15
+ const absoluteLink = path.resolve(linkPath);
16
+
17
+ // Ensure parent directory exists
18
+ const parentDir = path.dirname(absoluteLink);
19
+ if (!fs.existsSync(parentDir)) {
20
+ fs.mkdirSync(parentDir, { recursive: true });
21
+ }
22
+
23
+ // Remove existing link/directory if present
24
+ if (fs.existsSync(absoluteLink)) {
25
+ const stats = fs.lstatSync(absoluteLink);
26
+ if (stats.isSymbolicLink()) {
27
+ fs.unlinkSync(absoluteLink);
28
+ } else {
29
+ fs.rmSync(absoluteLink, { recursive: true, force: true });
30
+ }
31
+ }
32
+
33
+ // Use 'junction' on Windows (no admin required), 'dir' on Unix
34
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
35
+ fs.symlinkSync(absoluteTarget, absoluteLink, linkType);
36
+ }
37
+
38
+ /**
39
+ * Checks if a path is a symlink or junction.
40
+ *
41
+ * @param {string} linkPath - Path to check.
42
+ * @returns {boolean} True if it's a symlink/junction.
43
+ */
44
+ function isLink(linkPath) {
45
+ try {
46
+ const stats = fs.lstatSync(linkPath);
47
+ return stats.isSymbolicLink();
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Removes a symlink/junction without affecting the target directory.
55
+ *
56
+ * @param {string} linkPath - Path to the symlink/junction to remove.
57
+ * @returns {boolean} True if removed, false if it didn't exist or wasn't a link.
58
+ */
59
+ function removeLink(linkPath) {
60
+ try {
61
+ const stats = fs.lstatSync(linkPath);
62
+ if (stats.isSymbolicLink()) {
63
+ fs.unlinkSync(linkPath);
64
+ return true;
65
+ }
66
+ return false;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Gets the target path of a symlink/junction.
74
+ *
75
+ * @param {string} linkPath - Path to the symlink/junction.
76
+ * @returns {string|null} The target path, or null if not a link.
77
+ */
78
+ function getLinkTarget(linkPath) {
79
+ try {
80
+ const stats = fs.lstatSync(linkPath);
81
+ if (stats.isSymbolicLink()) {
82
+ return fs.readlinkSync(linkPath);
83
+ }
84
+ return null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ module.exports = {
91
+ createLink,
92
+ isLink,
93
+ removeLink,
94
+ getLinkTarget,
95
+ };