fss-link 1.5.7 → 1.6.0

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 (40) hide show
  1. package/README.md +1 -1
  2. package/bundle/fss-link.js +4785 -3183
  3. package/package.json +4 -1
  4. package/scripts/analyze-session-logs.sh +279 -0
  5. package/scripts/build.js +55 -0
  6. package/scripts/build_package.js +37 -0
  7. package/scripts/build_sandbox.js +195 -0
  8. package/scripts/build_vscode_companion.js +30 -0
  9. package/scripts/check-build-status.js +148 -0
  10. package/scripts/check-publish.js +101 -0
  11. package/scripts/clean.js +55 -0
  12. package/scripts/copy_bundle_assets.js +40 -0
  13. package/scripts/copy_files.js +56 -0
  14. package/scripts/create_alias.sh +39 -0
  15. package/scripts/emergency-kill-all-tests.sh +95 -0
  16. package/scripts/emergency-kill-vitest.sh +95 -0
  17. package/scripts/extract-session-logs.sh +202 -0
  18. package/scripts/generate-git-commit-info.js +71 -0
  19. package/scripts/get-previous-tag.js +213 -0
  20. package/scripts/get-release-version.js +119 -0
  21. package/scripts/index-session-logs.sh +173 -0
  22. package/scripts/install-linux.sh +294 -0
  23. package/scripts/install-macos.sh +343 -0
  24. package/scripts/install-windows.ps1 +427 -0
  25. package/scripts/local_telemetry.js +219 -0
  26. package/scripts/memory-monitor.sh +165 -0
  27. package/scripts/postinstall-message.js +31 -0
  28. package/scripts/prepare-package.js +51 -0
  29. package/scripts/process-session-log.py +302 -0
  30. package/scripts/quick-install.sh +195 -0
  31. package/scripts/sandbox_command.js +126 -0
  32. package/scripts/start.js +76 -0
  33. package/scripts/telemetry.js +85 -0
  34. package/scripts/telemetry_gcp.js +188 -0
  35. package/scripts/telemetry_utils.js +421 -0
  36. package/scripts/test-windows-paths.js +51 -0
  37. package/scripts/tests/get-release-version.test.js +110 -0
  38. package/scripts/tests/test-setup.ts +12 -0
  39. package/scripts/tests/vitest.config.ts +20 -0
  40. package/scripts/version.js +83 -0
@@ -0,0 +1,421 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @license
5
+ * Copyright 2025 Google LLC
6
+ * SPDX-License-Identifier: Apache-2.0
7
+ */
8
+
9
+ import path from 'path';
10
+ import fs from 'fs';
11
+ import net from 'net';
12
+ import os from 'os';
13
+ import { execSync } from 'child_process';
14
+ import { fileURLToPath } from 'url';
15
+ import crypto from 'node:crypto';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ const projectRoot = path.resolve(__dirname, '..');
21
+ const projectHash = crypto
22
+ .createHash('sha256')
23
+ .update(projectRoot)
24
+ .digest('hex');
25
+
26
+ // User-level .fss-link directory in home
27
+ const USER_GEMINI_DIR = path.join(os.homedir(), '.fss-link');
28
+ // Project-level .fss-link directory in the workspace
29
+ const WORKSPACE_GEMINI_DIR = path.join(projectRoot, '.fss-link');
30
+
31
+ // Telemetry artifacts are stored in a hashed directory under the user's ~/.fss-link/tmp
32
+ export const OTEL_DIR = path.join(USER_GEMINI_DIR, 'tmp', projectHash, 'otel');
33
+ export const BIN_DIR = path.join(OTEL_DIR, 'bin');
34
+
35
+ // Workspace settings remain in the project's .fss-link directory
36
+ export const WORKSPACE_SETTINGS_FILE = path.join(
37
+ WORKSPACE_GEMINI_DIR,
38
+ 'settings.json',
39
+ );
40
+
41
+ export function getJson(url) {
42
+ const tmpFile = path.join(
43
+ os.tmpdir(),
44
+ `fss-link-releases-${Date.now()}.json`,
45
+ );
46
+ try {
47
+ execSync(
48
+ `curl -sL -H "User-Agent: fss-link-dev-script" -o "${tmpFile}" "${url}"`,
49
+ { stdio: 'pipe' },
50
+ );
51
+ const content = fs.readFileSync(tmpFile, 'utf-8');
52
+ return JSON.parse(content);
53
+ } catch (e) {
54
+ console.error(`Failed to fetch or parse JSON from ${url}`);
55
+ throw e;
56
+ } finally {
57
+ if (fs.existsSync(tmpFile)) {
58
+ fs.unlinkSync(tmpFile);
59
+ }
60
+ }
61
+ }
62
+
63
+ export function downloadFile(url, dest) {
64
+ try {
65
+ execSync(`curl -fL -sS -o "${dest}" "${url}"`, {
66
+ stdio: 'pipe',
67
+ });
68
+ return dest;
69
+ } catch (e) {
70
+ console.error(`Failed to download file from ${url}`);
71
+ throw e;
72
+ }
73
+ }
74
+
75
+ export function findFile(startPath, filter) {
76
+ if (!fs.existsSync(startPath)) {
77
+ return null;
78
+ }
79
+ const files = fs.readdirSync(startPath);
80
+ for (const file of files) {
81
+ const filename = path.join(startPath, file);
82
+ const stat = fs.lstatSync(filename);
83
+ if (stat.isDirectory()) {
84
+ const result = findFile(filename, filter);
85
+ if (result) return result;
86
+ } else if (filter(file)) {
87
+ return filename;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ export function fileExists(filePath) {
94
+ return fs.existsSync(filePath);
95
+ }
96
+
97
+ export function readJsonFile(filePath) {
98
+ if (!fileExists(filePath)) {
99
+ return {};
100
+ }
101
+ const content = fs.readFileSync(filePath, 'utf-8');
102
+ try {
103
+ return JSON.parse(content);
104
+ } catch (e) {
105
+ console.error(`Error parsing JSON from ${filePath}: ${e.message}`);
106
+ return {};
107
+ }
108
+ }
109
+
110
+ export function writeJsonFile(filePath, data) {
111
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
112
+ }
113
+
114
+ export function moveBinary(source, destination) {
115
+ try {
116
+ fs.renameSync(source, destination);
117
+ } catch (error) {
118
+ if (error.code !== 'EXDEV') {
119
+ throw error;
120
+ }
121
+ // Handle a cross-device error: copy-to-temp-then-rename.
122
+ const destDir = path.dirname(destination);
123
+ const destFile = path.basename(destination);
124
+ const tempDest = path.join(destDir, `${destFile}.tmp`);
125
+
126
+ try {
127
+ fs.copyFileSync(source, tempDest);
128
+ fs.renameSync(tempDest, destination);
129
+ } catch (moveError) {
130
+ // If copy or rename fails, clean up the intermediate temp file.
131
+ if (fs.existsSync(tempDest)) {
132
+ fs.unlinkSync(tempDest);
133
+ }
134
+ throw moveError;
135
+ }
136
+ fs.unlinkSync(source);
137
+ }
138
+ }
139
+
140
+ export function waitForPort(port, timeout = 10000) {
141
+ return new Promise((resolve, reject) => {
142
+ const startTime = Date.now();
143
+ const tryConnect = () => {
144
+ const socket = new net.Socket();
145
+ socket.once('connect', () => {
146
+ socket.end();
147
+ resolve();
148
+ });
149
+ socket.once('error', (_) => {
150
+ if (Date.now() - startTime > timeout) {
151
+ reject(new Error(`Timeout waiting for port ${port} to open.`));
152
+ } else {
153
+ setTimeout(tryConnect, 500);
154
+ }
155
+ });
156
+ socket.connect(port, 'localhost');
157
+ };
158
+ tryConnect();
159
+ });
160
+ }
161
+
162
+ export async function ensureBinary(
163
+ executableName,
164
+ repo,
165
+ assetNameCallback,
166
+ binaryNameInArchive,
167
+ isJaeger = false,
168
+ ) {
169
+ const executablePath = path.join(BIN_DIR, executableName);
170
+ if (fileExists(executablePath)) {
171
+ console.log(`✅ ${executableName} already exists at ${executablePath}`);
172
+ return executablePath;
173
+ }
174
+
175
+ console.log(`🔍 ${executableName} not found. Downloading from ${repo}...`);
176
+
177
+ const platform = process.platform === 'win32' ? 'windows' : process.platform;
178
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch;
179
+ const ext = platform === 'windows' ? 'zip' : 'tar.gz';
180
+
181
+ if (isJaeger && platform === 'windows' && arch === 'arm64') {
182
+ console.warn(
183
+ `⚠️ Jaeger does not have a release for Windows on ARM64. Skipping.`,
184
+ );
185
+ return null;
186
+ }
187
+
188
+ let release;
189
+ let asset;
190
+
191
+ if (isJaeger) {
192
+ console.log(`🔍 Finding latest Jaeger v2+ asset...`);
193
+ const releases = getJson(`https://api.github.com/repos/${repo}/releases`);
194
+ const sortedReleases = releases
195
+ .filter((r) => !r.prerelease && r.tag_name.startsWith('v'))
196
+ .sort((a, b) => {
197
+ const aVersion = a.tag_name.substring(1).split('.').map(Number);
198
+ const bVersion = b.tag_name.substring(1).split('.').map(Number);
199
+ for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) {
200
+ if ((aVersion[i] || 0) > (bVersion[i] || 0)) return -1;
201
+ if ((aVersion[i] || 0) < (bVersion[i] || 0)) return 1;
202
+ }
203
+ return 0;
204
+ });
205
+
206
+ for (const r of sortedReleases) {
207
+ const expectedSuffix =
208
+ platform === 'windows'
209
+ ? `-${platform}-${arch}.zip`
210
+ : `-${platform}-${arch}.tar.gz`;
211
+ const foundAsset = r.assets.find(
212
+ (a) =>
213
+ a.name.startsWith('jaeger-2.') && a.name.endsWith(expectedSuffix),
214
+ );
215
+
216
+ if (foundAsset) {
217
+ release = r;
218
+ asset = foundAsset;
219
+ console.log(
220
+ `⬇️ Found ${asset.name} in release ${r.tag_name}, downloading...`,
221
+ );
222
+ break;
223
+ }
224
+ }
225
+ if (!asset) {
226
+ throw new Error(
227
+ `Could not find a suitable Jaeger v2 asset for platform ${platform}/${arch}.`,
228
+ );
229
+ }
230
+ } else {
231
+ release = getJson(`https://api.github.com/repos/${repo}/releases/latest`);
232
+ const version = release.tag_name.startsWith('v')
233
+ ? release.tag_name.substring(1)
234
+ : release.tag_name;
235
+ const assetName = assetNameCallback(version, platform, arch, ext);
236
+ asset = release.assets.find((a) => a.name === assetName);
237
+ if (!asset) {
238
+ throw new Error(
239
+ `Could not find a suitable asset for ${repo} (version ${version}) on platform ${platform}/${arch}. Searched for: ${assetName}`,
240
+ );
241
+ }
242
+ }
243
+
244
+ const downloadUrl = asset.browser_download_url;
245
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fss-link-telemetry-'));
246
+ const archivePath = path.join(tmpDir, asset.name);
247
+
248
+ try {
249
+ console.log(`⬇️ Downloading ${asset.name}...`);
250
+ downloadFile(downloadUrl, archivePath);
251
+ console.log(`📦 Extracting ${asset.name}...`);
252
+
253
+ const actualExt = asset.name.endsWith('.zip') ? 'zip' : 'tar.gz';
254
+
255
+ if (actualExt === 'zip') {
256
+ execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`, { stdio: 'pipe' });
257
+ } else {
258
+ execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' });
259
+ }
260
+
261
+ const nameToFind = binaryNameInArchive || executableName;
262
+ const foundBinaryPath = findFile(tmpDir, (file) => {
263
+ if (platform === 'windows') {
264
+ return file === `${nameToFind}.exe`;
265
+ }
266
+ return file === nameToFind;
267
+ });
268
+
269
+ if (!foundBinaryPath) {
270
+ throw new Error(
271
+ `Could not find binary "${nameToFind}" in extracted archive at ${tmpDir}. Contents: ${fs.readdirSync(tmpDir).join(', ')}`,
272
+ );
273
+ }
274
+
275
+ moveBinary(foundBinaryPath, executablePath);
276
+
277
+ if (platform !== 'windows') {
278
+ fs.chmodSync(executablePath, '755');
279
+ }
280
+
281
+ console.log(`✅ ${executableName} installed at ${executablePath}`);
282
+ return executablePath;
283
+ } finally {
284
+ fs.rmSync(tmpDir, { recursive: true, force: true });
285
+ if (fs.existsSync(archivePath)) {
286
+ fs.unlinkSync(archivePath);
287
+ }
288
+ }
289
+ }
290
+
291
+ export function manageTelemetrySettings(
292
+ enable,
293
+ oTelEndpoint = 'http://localhost:4317',
294
+ target = 'local',
295
+ originalSandboxSettingToRestore,
296
+ ) {
297
+ const workspaceSettings = readJsonFile(WORKSPACE_SETTINGS_FILE);
298
+ const currentSandboxSetting = workspaceSettings.sandbox;
299
+ let settingsModified = false;
300
+
301
+ if (typeof workspaceSettings.telemetry !== 'object') {
302
+ workspaceSettings.telemetry = {};
303
+ }
304
+
305
+ if (enable) {
306
+ if (workspaceSettings.telemetry.enabled !== true) {
307
+ workspaceSettings.telemetry.enabled = true;
308
+ settingsModified = true;
309
+ console.log('⚙️ Enabled telemetry in workspace settings.');
310
+ }
311
+ if (workspaceSettings.sandbox !== false) {
312
+ workspaceSettings.sandbox = false;
313
+ settingsModified = true;
314
+ console.log('✅ Disabled sandbox mode for telemetry.');
315
+ }
316
+ if (workspaceSettings.telemetry.otlpEndpoint !== oTelEndpoint) {
317
+ workspaceSettings.telemetry.otlpEndpoint = oTelEndpoint;
318
+ settingsModified = true;
319
+ console.log(`🔧 Set telemetry OTLP endpoint to ${oTelEndpoint}.`);
320
+ }
321
+ if (workspaceSettings.telemetry.target !== target) {
322
+ workspaceSettings.telemetry.target = target;
323
+ settingsModified = true;
324
+ console.log(`🎯 Set telemetry target to ${target}.`);
325
+ }
326
+ } else {
327
+ if (workspaceSettings.telemetry.enabled === true) {
328
+ delete workspaceSettings.telemetry.enabled;
329
+ settingsModified = true;
330
+ console.log('⚙️ Disabled telemetry in workspace settings.');
331
+ }
332
+ if (workspaceSettings.telemetry.otlpEndpoint) {
333
+ delete workspaceSettings.telemetry.otlpEndpoint;
334
+ settingsModified = true;
335
+ console.log('🔧 Cleared telemetry OTLP endpoint.');
336
+ }
337
+ if (workspaceSettings.telemetry.target) {
338
+ delete workspaceSettings.telemetry.target;
339
+ settingsModified = true;
340
+ console.log('🎯 Cleared telemetry target.');
341
+ }
342
+ if (Object.keys(workspaceSettings.telemetry).length === 0) {
343
+ delete workspaceSettings.telemetry;
344
+ }
345
+
346
+ if (
347
+ originalSandboxSettingToRestore !== undefined &&
348
+ workspaceSettings.sandbox !== originalSandboxSettingToRestore
349
+ ) {
350
+ workspaceSettings.sandbox = originalSandboxSettingToRestore;
351
+ settingsModified = true;
352
+ console.log('✅ Restored original sandbox setting.');
353
+ }
354
+ }
355
+
356
+ if (settingsModified) {
357
+ writeJsonFile(WORKSPACE_SETTINGS_FILE, workspaceSettings);
358
+ console.log('✅ Workspace settings updated.');
359
+ } else {
360
+ console.log(
361
+ enable
362
+ ? '✅ Workspace settings are already configured for telemetry.'
363
+ : '✅ Workspace settings already reflect telemetry disabled.',
364
+ );
365
+ }
366
+ return currentSandboxSetting;
367
+ }
368
+
369
+ export function registerCleanup(
370
+ getProcesses,
371
+ getLogFileDescriptors,
372
+ originalSandboxSetting,
373
+ ) {
374
+ let cleanedUp = false;
375
+ const cleanup = () => {
376
+ if (cleanedUp) return;
377
+ cleanedUp = true;
378
+
379
+ console.log('\n👋 Shutting down...');
380
+
381
+ manageTelemetrySettings(false, null, originalSandboxSetting);
382
+
383
+ const processes = getProcesses ? getProcesses() : [];
384
+ processes.forEach((proc) => {
385
+ if (proc && proc.pid) {
386
+ const name = path.basename(proc.spawnfile);
387
+ try {
388
+ console.log(`🛑 Stopping ${name} (PID: ${proc.pid})...`);
389
+ process.kill(proc.pid, 'SIGTERM');
390
+ console.log(`✅ ${name} stopped.`);
391
+ } catch (e) {
392
+ if (e.code !== 'ESRCH') {
393
+ console.error(`Error stopping ${name}: ${e.message}`);
394
+ }
395
+ }
396
+ }
397
+ });
398
+
399
+ const logFileDescriptors = getLogFileDescriptors
400
+ ? getLogFileDescriptors()
401
+ : [];
402
+ logFileDescriptors.forEach((fd) => {
403
+ if (fd) {
404
+ try {
405
+ fs.closeSync(fd);
406
+ } catch (_) {
407
+ /* no-op */
408
+ }
409
+ }
410
+ });
411
+ };
412
+
413
+ process.on('exit', cleanup);
414
+ process.on('SIGINT', () => process.exit(0));
415
+ process.on('SIGTERM', () => process.exit(0));
416
+ process.on('uncaughtException', (err) => {
417
+ console.error('Uncaught Exception:', err);
418
+ cleanup();
419
+ process.exit(1);
420
+ });
421
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ // Test how paths are normalized
11
+ function testPathNormalization() {
12
+ // Use platform-agnostic path construction instead of hardcoded paths
13
+ const testPath = path.join('test', 'project', 'src', 'file.md');
14
+ const absoluteTestPath = path.resolve('test', 'project', 'src', 'file.md');
15
+
16
+ console.log('Testing path normalization:');
17
+ console.log('Relative path:', testPath);
18
+ console.log('Absolute path:', absoluteTestPath);
19
+
20
+ // Test path.join with different segments
21
+ const joinedPath = path.join('test', 'project', 'src', 'file.md');
22
+ console.log('Joined path:', joinedPath);
23
+
24
+ // Test path.normalize
25
+ console.log('Normalized relative path:', path.normalize(testPath));
26
+ console.log('Normalized absolute path:', path.normalize(absoluteTestPath));
27
+
28
+ // Test how the test would see these paths
29
+ const testContent = `--- File: ${absoluteTestPath} ---\nContent\n--- End of File: ${absoluteTestPath} ---`;
30
+ console.log('\nTest content with platform-agnostic paths:');
31
+ console.log(testContent);
32
+
33
+ // Try to match with different patterns
34
+ const marker = `--- File: ${absoluteTestPath} ---`;
35
+ console.log('\nTrying to match:', marker);
36
+ console.log('Direct match:', testContent.includes(marker));
37
+
38
+ // Test with normalized path in marker
39
+ const normalizedMarker = `--- File: ${path.normalize(absoluteTestPath)} ---`;
40
+ console.log(
41
+ 'Normalized marker match:',
42
+ testContent.includes(normalizedMarker),
43
+ );
44
+
45
+ // Test path resolution
46
+ const __filename = fileURLToPath(import.meta.url);
47
+ console.log('\nCurrent file path:', __filename);
48
+ console.log('Directory name:', path.dirname(__filename));
49
+ }
50
+
51
+ testPathNormalization();
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { getReleaseVersion } from '../get-release-version';
9
+ import { execSync } from 'child_process';
10
+ import * as fs from 'fs';
11
+
12
+ vi.mock('child_process', () => ({
13
+ execSync: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('fs', async (importOriginal) => {
17
+ const mod = await importOriginal();
18
+ return {
19
+ ...mod,
20
+ default: {
21
+ ...mod.default,
22
+ readFileSync: vi.fn(),
23
+ },
24
+ };
25
+ });
26
+
27
+ describe('getReleaseVersion', () => {
28
+ const originalEnv = { ...process.env };
29
+
30
+ beforeEach(() => {
31
+ vi.resetModules();
32
+ process.env = { ...originalEnv };
33
+ vi.useFakeTimers();
34
+ });
35
+
36
+ afterEach(() => {
37
+ process.env = originalEnv;
38
+ vi.clearAllMocks();
39
+ vi.useRealTimers();
40
+ });
41
+
42
+ it('should calculate nightly version when IS_NIGHTLY is true', () => {
43
+ process.env.IS_NIGHTLY = 'true';
44
+ vi.mocked(fs.default.readFileSync).mockReturnValue(
45
+ JSON.stringify({ version: '0.1.0' }),
46
+ );
47
+ // Mock git tag command to return empty (no existing nightly tags)
48
+ vi.mocked(execSync).mockReturnValue('');
49
+ const { releaseTag, releaseVersion, npmTag } = getReleaseVersion();
50
+ expect(releaseTag).toBe('v0.1.1-nightly.0');
51
+ expect(releaseVersion).toBe('0.1.1-nightly.0');
52
+ expect(npmTag).toBe('nightly');
53
+ });
54
+
55
+ it('should use manual version when provided', () => {
56
+ process.env.MANUAL_VERSION = '1.2.3';
57
+ const { releaseTag, releaseVersion, npmTag } = getReleaseVersion();
58
+ expect(releaseTag).toBe('v1.2.3');
59
+ expect(releaseVersion).toBe('1.2.3');
60
+ expect(npmTag).toBe('latest');
61
+ });
62
+
63
+ it('should prepend v to manual version if missing', () => {
64
+ process.env.MANUAL_VERSION = '1.2.3';
65
+ const { releaseTag } = getReleaseVersion();
66
+ expect(releaseTag).toBe('v1.2.3');
67
+ });
68
+
69
+ it('should handle pre-release versions correctly', () => {
70
+ process.env.MANUAL_VERSION = 'v1.2.3-beta.1';
71
+ const { releaseTag, releaseVersion, npmTag } = getReleaseVersion();
72
+ expect(releaseTag).toBe('v1.2.3-beta.1');
73
+ expect(releaseVersion).toBe('1.2.3-beta.1');
74
+ expect(npmTag).toBe('beta');
75
+ });
76
+
77
+ it('should throw an error for invalid version format', () => {
78
+ process.env.MANUAL_VERSION = '1.2';
79
+ expect(() => getReleaseVersion()).toThrow(
80
+ 'Error: Version must be in the format vX.Y.Z or vX.Y.Z-prerelease',
81
+ );
82
+ });
83
+
84
+ it('should throw an error if no version is provided for non-nightly release', () => {
85
+ expect(() => getReleaseVersion()).toThrow(
86
+ 'Error: No version specified and this is not a nightly release.',
87
+ );
88
+ });
89
+
90
+ it('should throw an error for versions with build metadata', () => {
91
+ process.env.MANUAL_VERSION = 'v1.2.3+build456';
92
+ expect(() => getReleaseVersion()).toThrow(
93
+ 'Error: Versions with build metadata (+) are not supported for releases.',
94
+ );
95
+ });
96
+ });
97
+
98
+ describe('get-release-version script', () => {
99
+ it('should print version JSON to stdout when executed directly', () => {
100
+ const expectedJson = {
101
+ releaseTag: 'v0.1.1-nightly.0',
102
+ releaseVersion: '0.1.1-nightly.0',
103
+ npmTag: 'nightly',
104
+ };
105
+ execSync.mockReturnValue(JSON.stringify(expectedJson));
106
+
107
+ const result = execSync('node scripts/get-release-version.js').toString();
108
+ expect(JSON.parse(result)).toEqual(expectedJson);
109
+ });
110
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { vi } from 'vitest';
8
+
9
+ vi.mock('fs', () => ({
10
+ ...vi.importActual('fs'),
11
+ appendFileSync: vi.fn(),
12
+ }));
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { defineConfig } from 'vitest/config';
8
+
9
+ export default defineConfig({
10
+ test: {
11
+ globals: true,
12
+ environment: 'node',
13
+ include: ['scripts/tests/**/*.test.js'],
14
+ setupFiles: ['scripts/tests/test-setup.ts'],
15
+ coverage: {
16
+ provider: 'v8',
17
+ reporter: ['text', 'lcov'],
18
+ },
19
+ },
20
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { execSync } from 'child_process';
8
+ import { readFileSync, writeFileSync } from 'fs';
9
+ import { resolve } from 'path';
10
+
11
+ // A script to handle versioning and ensure all related changes are in a single, atomic commit.
12
+
13
+ function run(command) {
14
+ console.log(`> ${command}`);
15
+ execSync(command, { stdio: 'inherit' });
16
+ }
17
+
18
+ function readJson(filePath) {
19
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
20
+ }
21
+
22
+ function writeJson(filePath, data) {
23
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
24
+ }
25
+
26
+ // 1. Get the version from the command line arguments.
27
+ const versionType = process.argv[2];
28
+ if (!versionType) {
29
+ console.error('Error: No version specified.');
30
+ console.error(
31
+ 'Usage: npm run version <version> (e.g., 1.2.3 or patch|minor|major|prerelease)',
32
+ );
33
+ process.exit(1);
34
+ }
35
+
36
+ // 2. Bump the version in the root and all workspace package.json files.
37
+ run(`npm version ${versionType} --no-git-tag-version --allow-same-version`);
38
+
39
+ // 3. Get all workspaces and filter out the one we don't want to version.
40
+ const workspacesToExclude = [];
41
+ const lsOutput = JSON.parse(
42
+ execSync('npm ls --workspaces --json --depth=0').toString(),
43
+ );
44
+ const allWorkspaces = Object.keys(lsOutput.dependencies || {});
45
+ const workspacesToVersion = allWorkspaces.filter(
46
+ (wsName) => !workspacesToExclude.includes(wsName),
47
+ );
48
+
49
+ for (const workspaceName of workspacesToVersion) {
50
+ run(
51
+ `npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version`,
52
+ );
53
+ }
54
+
55
+ // 4. Get the new version number from the root package.json
56
+ const rootPackageJsonPath = resolve(process.cwd(), 'package.json');
57
+ const newVersion = readJson(rootPackageJsonPath).version;
58
+
59
+ // 6. Update the sandboxImageUri in the root package.json
60
+ const rootPackageJson = readJson(rootPackageJsonPath);
61
+ if (rootPackageJson.config?.sandboxImageUri) {
62
+ rootPackageJson.config.sandboxImageUri =
63
+ rootPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`);
64
+ console.log(`Updated sandboxImageUri in root to use version ${newVersion}`);
65
+ writeJson(rootPackageJsonPath, rootPackageJson);
66
+ }
67
+
68
+ // 7. Update the sandboxImageUri in the cli package.json
69
+ const cliPackageJsonPath = resolve(process.cwd(), 'packages/cli/package.json');
70
+ const cliPackageJson = readJson(cliPackageJsonPath);
71
+ if (cliPackageJson.config?.sandboxImageUri) {
72
+ cliPackageJson.config.sandboxImageUri =
73
+ cliPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`);
74
+ console.log(
75
+ `Updated sandboxImageUri in cli package to use version ${newVersion}`,
76
+ );
77
+ writeJson(cliPackageJsonPath, cliPackageJson);
78
+ }
79
+
80
+ // 8. Run `npm install` to update package-lock.json.
81
+ run('npm install');
82
+
83
+ console.log(`Successfully bumped versions to v${newVersion}.`);