diff --git a/.bitmap b/.bitmap index 44c934c5f960..1964f0b03cae 100644 --- a/.bitmap +++ b/.bitmap @@ -877,6 +877,14 @@ "mainFile": "index.ts", "rootDir": "scopes/defender/jest" }, + "json/jsonc-utils": { + "name": "json/jsonc-utils", + "scope": "", + "version": "", + "defaultScope": "teambit.toolbox", + "mainFile": "index.ts", + "rootDir": "scopes/toolbox/json/jsonc-utils" + }, "lanes": { "name": "lanes", "scope": "teambit.lanes", diff --git a/e2e/harmony/dependencies/env-jsonc-policies.e2e.ts b/e2e/harmony/dependencies/env-jsonc-policies.e2e.ts index 03ebe5632fc0..3b4ef57cdebc 100644 --- a/e2e/harmony/dependencies/env-jsonc-policies.e2e.ts +++ b/e2e/harmony/dependencies/env-jsonc-policies.e2e.ts @@ -498,4 +498,71 @@ describe('env-jsonc-policies', function () { }); }); }); + describe('bit update', function () { + let envId; + before(() => { + helper = new Helper(); + helper.scopeHelper.setWorkspaceWithRemoteScope(); + envId = 'react-based-env'; + helper.env.setCustomNewEnv(undefined, undefined, { + policy: { + runtime: [ + { + name: 'is-string', + version: '1.0.5', + force: true, + }, + ], + }, + }, false, envId); + helper.command.install(); + }); + after(() => { + helper.scopeHelper.destroy(); + }); + + it('should update env.jsonc dependency to latest version', () => { + const envJsoncPath = path.join(helper.scopes.localPath, envId, 'env.jsonc'); + const originalEnvJsonc = fs.readJsonSync(envJsoncPath); + expect(originalEnvJsonc.policy.runtime[0].version).to.equal('1.0.5'); + + helper.command.update('is-string --yes'); + + const updatedEnvJsonc = fs.readJsonSync(envJsoncPath); + expect(updatedEnvJsonc.policy.runtime[0].version).to.not.equal('1.0.5'); + }); + + it('should update supportedRange for peerDependencies when new version is outside existing range', () => { + const envId2 = 'react-based-env-peers'; + helper.env.setCustomNewEnv(undefined, undefined, { + policy: { + peers: [ + { + name: 'react', + version: '16.8.0', + supportedRange: '^16.8.0', + }, + ], + }, + }, false, envId2); + helper.command.install(); + + const envJsoncPath = path.join(helper.scopes.localPath, envId2, 'env.jsonc'); + const originalEnvJsonc = fs.readJsonSync(envJsoncPath); + expect(originalEnvJsonc.policy.peers[0].supportedRange).to.equal('^16.8.0'); + + // Update react to latest (which is > 16.8.0, likely 18.x) + helper.command.update('react --yes'); + + const updatedEnvJsonc = fs.readJsonSync(envJsoncPath); + const newVersion = updatedEnvJsonc.policy.peers[0].version; + const newSupportedRange = updatedEnvJsonc.policy.peers[0].supportedRange; + + expect(newVersion).to.not.equal('16.8.0'); + // Should now contain the old range OR the new range/version + expect(newSupportedRange).to.include(' || '); + expect(newSupportedRange).to.include('^16.8.0'); + expect(newSupportedRange).to.include(newVersion); + }); + }); }); diff --git a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts index 7ffb034058aa..3e3cc86e31de 100644 --- a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts +++ b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import multimatch from 'multimatch'; import { isSnap } from '@teambit/component-version'; import { BitError } from '@teambit/bit-error'; @@ -37,7 +38,7 @@ import { Http } from '@teambit/scope.network'; import type { Dependency as LegacyDependency } from '@teambit/legacy.consumer-component'; import { ConsumerComponent as LegacyComponent } from '@teambit/legacy.consumer-component'; import fs from 'fs-extra'; -import { assign } from 'comment-json'; +import { assign, parse } from 'comment-json'; import { ComponentID } from '@teambit/component-id'; import { readCAFileSync } from '@pnpm/network.ca-file'; import { parseBareSpecifier } from '@pnpm/npm-resolver'; @@ -88,7 +89,7 @@ import { DependenciesFragment, DevDependenciesFragment, PeerDependenciesFragment import { dependencyResolverSchema } from './dependency-resolver.graphql'; import type { DependencyDetector } from './detector-hook'; import { DependenciesService } from './dependencies.service'; -import { EnvPolicy } from './policy/env-policy'; +import { EnvPolicy, type EnvJsoncPolicyEntry } from './policy/env-policy'; import type { ConfigStoreMain } from '@teambit/config-store'; import { ConfigStoreAspect } from '@teambit/config-store'; @@ -1493,6 +1494,7 @@ as an alternative, you can use "+" to keep the same version installed in the wor variantPoliciesByPatterns, componentPolicies, components, + includeEnvJsoncDeps: true, }); if (patterns?.length) { const selectedPkgNames = new Set( @@ -1514,10 +1516,12 @@ as an alternative, you can use "+" to keep the same version installed in the wor variantPoliciesByPatterns, componentPolicies, components, + includeEnvJsoncDeps = false, }: { variantPoliciesByPatterns: Record; componentPolicies: Array<{ componentId: ComponentID; policy: any }>; components: Component[]; + includeEnvJsoncDeps?: boolean; }): CurrentPkg[] { const localComponentPkgNames = new Set(components.map((component) => this.getPackageName(component))); const componentModelVersions: ComponentModelVersion[] = components @@ -1542,12 +1546,55 @@ as an alternative, you can use "+" to keep the same version installed in the wor })); }) .flat(); - return getAllPolicyPkgs({ - rootPolicy: this.getWorkspacePolicyFromConfig(), - variantPoliciesByPatterns, - componentPolicies, - componentModelVersions, - }); + return [ + ...getAllPolicyPkgs({ + rootPolicy: this.getWorkspacePolicyFromConfig(), + variantPoliciesByPatterns, + componentPolicies, + componentModelVersions, + }), + ...(includeEnvJsoncDeps ? this.getEnvJsoncPolicyPkgs(components) : []), + ]; + } + + getEnvJsoncPolicyPkgs(components: Component[]): CurrentPkg[] { + const policies = [ + { field: 'peers', targetField: 'peerDependencies' as const }, + { field: 'dev', targetField: 'devDependencies' as const }, + { field: 'runtime', targetField: 'dependencies' as const }, + ]; + const pkgs: CurrentPkg[] = []; + for (const component of components) { + const isEnv = this.envs.isEnv(component); + if (!isEnv) continue; + + const envJsoncFile = component.filesystem.files.find((file) => file.relative === 'env.jsonc'); + if (!envJsoncFile) continue; + + let envJsonc: EnvJsonc; + try { + envJsonc = parse(envJsoncFile.contents.toString()) as EnvJsonc; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to parse env.jsonc for component ${component.id.toString()}: ${errorMessage}`); + continue; + } + if (!envJsonc.policy) continue; + + for (const { field, targetField } of policies) { + const deps: EnvJsoncPolicyEntry[] = envJsonc.policy?.[field] || []; + for (const dep of deps) { + pkgs.push({ + name: dep.name, + currentRange: dep.version, + source: 'env-jsonc', + componentId: component.id, + targetField, + }); + } + } + } + return pkgs; } getAllDedupedDirectDependencies(opts: { @@ -1618,9 +1665,7 @@ as an alternative, you can use "+" to keep the same version installed in the wor rootDir: string; forceVersionBump?: 'major' | 'minor' | 'patch' | 'compatible'; }, - pkgs: Array< - { name: string; currentRange: string; source: 'variants' | 'component' | 'rootPolicy' | 'component-model' } & T - > + pkgs: Array<{ name: string; currentRange: string; source: CurrentPkgSource; } & T> ): Promise> { this.logger.setStatusLine('checking the latest versions of dependencies'); const resolver = await this.getVersionResolver(); diff --git a/scopes/dependencies/dependency-resolver/get-all-policy-pkgs.ts b/scopes/dependencies/dependency-resolver/get-all-policy-pkgs.ts index 1f3adf9956c8..4e4a925ebe25 100644 --- a/scopes/dependencies/dependency-resolver/get-all-policy-pkgs.ts +++ b/scopes/dependencies/dependency-resolver/get-all-policy-pkgs.ts @@ -12,7 +12,9 @@ export type CurrentPkgSource = // these are dependencies from the dependencies policy in "workspace.jsonc" | 'rootPolicy' // these are dependencies stored in the component object (snapped/tagged version) - | 'component-model'; + | 'component-model' + // these are dependencies defined in env.jsonc files + | 'env-jsonc'; export type CurrentPkg = { name: string; diff --git a/scopes/dependencies/dependency-resolver/index.ts b/scopes/dependencies/dependency-resolver/index.ts index 9e1af1ef0c54..7d856e535ac9 100644 --- a/scopes/dependencies/dependency-resolver/index.ts +++ b/scopes/dependencies/dependency-resolver/index.ts @@ -46,7 +46,7 @@ export type { SemverVersion, DependenciesManifest, } from './dependencies'; -export { WorkspacePolicy, VariantPolicy, EnvPolicy } from './policy'; +export { WorkspacePolicy, VariantPolicy, EnvPolicy, EnvPolicyEnvJsoncConfigObject } from './policy'; export type { WorkspacePolicyEntry, WorkspacePolicyConfigObject, diff --git a/scopes/dependencies/dependency-resolver/policy/env-policy/index.ts b/scopes/dependencies/dependency-resolver/policy/env-policy/index.ts index 124e256f936b..40133e00bd77 100644 --- a/scopes/dependencies/dependency-resolver/policy/env-policy/index.ts +++ b/scopes/dependencies/dependency-resolver/policy/env-policy/index.ts @@ -1 +1 @@ -export { EnvPolicy, EnvPolicyConfigObject } from './env-policy'; +export { EnvPolicy, EnvPolicyConfigObject, EnvJsoncPolicyEntry, EnvPolicyEnvJsoncConfigObject } from './env-policy'; diff --git a/scopes/dependencies/dependency-resolver/policy/index.ts b/scopes/dependencies/dependency-resolver/policy/index.ts index e60960c5a210..8d35969af344 100644 --- a/scopes/dependencies/dependency-resolver/policy/index.ts +++ b/scopes/dependencies/dependency-resolver/policy/index.ts @@ -15,4 +15,4 @@ export { VariantPolicyConfigArr, } from './variant-policy'; -export { EnvPolicy, EnvPolicyConfigObject } from './env-policy'; +export { EnvPolicy, EnvPolicyConfigObject, EnvPolicyEnvJsoncConfigObject } from './env-policy'; diff --git a/scopes/toolbox/json/jsonc-utils/index.ts b/scopes/toolbox/json/jsonc-utils/index.ts new file mode 100644 index 000000000000..3c9aa373a1ad --- /dev/null +++ b/scopes/toolbox/json/jsonc-utils/index.ts @@ -0,0 +1,7 @@ +export { + detectJsoncFormatting, + parseJsoncWithFormatting, + stringifyJsonc, + updateJsoncPreservingFormatting, + type JsoncFormatting, +} from './jsonc-utils'; \ No newline at end of file diff --git a/scopes/toolbox/json/jsonc-utils/jsonc-utils.docs.mdx b/scopes/toolbox/json/jsonc-utils/jsonc-utils.docs.mdx new file mode 100644 index 000000000000..39fdd78692c3 --- /dev/null +++ b/scopes/toolbox/json/jsonc-utils/jsonc-utils.docs.mdx @@ -0,0 +1,91 @@ +--- +labels: ['typescript', 'utils', 'json', 'jsonc', 'formatting', 'comments', 'parser'] +description: 'Utilities for parsing and stringifying JSONC files while preserving formatting and comments.' +--- + +# JSONC Utils + +Utilities for working with JSONC (JSON with Comments) files while preserving their original formatting, including: +- **Indentation style** (2 spaces, 4 spaces, tabs) +- **Newline characters** (LF, CRLF) +- **Comments** + +This is particularly useful when you need to programmatically update configuration files without losing their human-readable formatting. + +## Installation + +```bash +bit install @teambit/toolbox.json.jsonc-utils +``` + +## Quick Start + +The easiest way to update a JSONC file while preserving its formatting: + +```ts +import { updateJsoncPreservingFormatting } from '@teambit/toolbox.json.jsonc-utils'; + +const originalContent = `{ + // This is a comment + "name": "my-package", + "version": "1.0.0" +}`; + +const updatedContent = updateJsoncPreservingFormatting(originalContent, (data) => { + data.version = "2.0.0"; + return data; +}); + +// Output preserves comments and formatting: +// { +// // This is a comment +// "name": "my-package", +// "version": "2.0.0" +// } +``` + +## Use Cases + +### Updating env.jsonc Files + +When updating component environment configurations, preserve the original formatting: + +```ts +import fs from 'fs-extra'; +import { updateJsoncPreservingFormatting } from '@teambit/toolbox.json.jsonc-utils'; + +async function updateEnvDependency( + envJsoncPath: string, + pkgName: string, + newVersion: string +) { + const content = await fs.readFile(envJsoncPath, 'utf-8'); + + const updated = updateJsoncPreservingFormatting(content, (envJsonc) => { + const dep = envJsonc.policy?.runtime?.find((d) => d.name === pkgName); + if (dep) { + dep.version = newVersion; + } + return envJsonc; + }); + + await fs.writeFile(envJsoncPath, updated); +} +``` + +### Working with Different Indentation Styles + +Handle files with different formatting preferences: + +```ts +import { detectJsoncFormatting, stringifyJsonc } from '@teambit/toolbox.json.jsonc-utils'; + +// Detect existing formatting +const formatting = detectJsoncFormatting(originalContent); + +// Modify data +const updatedData = { ...parsedData, version: '2.0.0' }; + +// Stringify with original formatting +const result = stringifyJsonc(updatedData, formatting); +``` diff --git a/scopes/toolbox/json/jsonc-utils/jsonc-utils.spec.ts b/scopes/toolbox/json/jsonc-utils/jsonc-utils.spec.ts new file mode 100644 index 000000000000..71ad5bead474 --- /dev/null +++ b/scopes/toolbox/json/jsonc-utils/jsonc-utils.spec.ts @@ -0,0 +1,339 @@ +import { expect } from 'chai'; +import { + detectJsoncFormatting, + parseJsoncWithFormatting, + stringifyJsonc, + updateJsoncPreservingFormatting, + type JsoncFormatting, +} from './jsonc-utils'; + +describe('jsonc-utils', () => { + describe('detectJsoncFormatting', () => { + it('should detect 2-space indentation', () => { + const content = `{ + "foo": "bar" +}`; + const formatting = detectJsoncFormatting(content); + expect(formatting.indent).to.equal(' '); + }); + + it('should detect 4-space indentation', () => { + const content = `{ + "foo": "bar" +}`; + const formatting = detectJsoncFormatting(content); + expect(formatting.indent).to.equal(' '); + }); + + it('should detect tab indentation', () => { + const content = `{ +\t"foo": "bar" +}`; + const formatting = detectJsoncFormatting(content); + expect(formatting.indent).to.equal('\t'); + }); + + it('should detect LF newlines', () => { + const content = '{\n "foo": "bar"\n}'; + const formatting = detectJsoncFormatting(content); + expect(formatting.newline).to.equal('\n'); + }); + + it('should detect CRLF newlines', () => { + const content = '{\r\n "foo": "bar"\r\n}'; + const formatting = detectJsoncFormatting(content); + expect(formatting.newline).to.equal('\r\n'); + }); + + it('should use default 2-space indent when no indentation detected', () => { + const content = '{"foo":"bar"}'; + const formatting = detectJsoncFormatting(content); + expect(formatting.indent).to.equal(' '); + }); + + it('should use default LF newline when no newlines detected', () => { + const content = '{"foo":"bar"}'; + const formatting = detectJsoncFormatting(content); + expect(formatting.newline).to.equal('\n'); + }); + }); + + describe('parseJsoncWithFormatting', () => { + it('should parse valid JSONC with comments', () => { + const content = `{ + // This is a comment + "foo": "bar", + /* Multi-line + comment */ + "baz": 123 +}`; + const { data, formatting } = parseJsoncWithFormatting(content); + expect(data.foo).to.equal('bar'); + expect(data.baz).to.equal(123); + expect(formatting.indent).to.equal(' '); + expect(formatting.newline).to.equal('\n'); + }); + + it('should parse JSONC and detect formatting together', () => { + const content = `{ + "name": "test", + "version": "1.0.0" +}`; + const { data, formatting } = parseJsoncWithFormatting(content); + expect(data.name).to.equal('test'); + expect(data.version).to.equal('1.0.0'); + expect(formatting.indent).to.equal(' '); + }); + + it('should handle arrays in JSONC', () => { + const content = `{ + "items": [1, 2, 3] +}`; + const { data } = parseJsoncWithFormatting(content); + expect(data.items).to.deep.equal([1, 2, 3]); + }); + + it('should handle nested objects', () => { + const content = `{ + "outer": { + "inner": "value" + } +}`; + const { data } = parseJsoncWithFormatting(content); + expect(data.outer.inner).to.equal('value'); + }); + }); + + describe('stringifyJsonc', () => { + it('should stringify with 2-space indentation', () => { + const data = { foo: 'bar', baz: 123 }; + const formatting: JsoncFormatting = { indent: ' ', newline: '\n' }; + const result = stringifyJsonc(data, formatting); + expect(result).to.equal('{\n "foo": "bar",\n "baz": 123\n}'); + }); + + it('should stringify with 4-space indentation', () => { + const data = { foo: 'bar' }; + const formatting: JsoncFormatting = { indent: ' ', newline: '\n' }; + const result = stringifyJsonc(data, formatting); + expect(result).to.equal('{\n "foo": "bar"\n}'); + }); + + it('should stringify with tab indentation', () => { + const data = { foo: 'bar' }; + const formatting: JsoncFormatting = { indent: '\t', newline: '\n' }; + const result = stringifyJsonc(data, formatting); + expect(result).to.equal('{\n\t"foo": "bar"\n}'); + }); + + it('should stringify with LF newlines', () => { + const data = { foo: 'bar' }; + const formatting: JsoncFormatting = { indent: ' ', newline: '\n' }; + const result = stringifyJsonc(data, formatting); + expect(result).to.include('\n'); + expect(result).to.not.include('\r\n'); + }); + + it('should stringify with CRLF newlines', () => { + const data = { foo: 'bar' }; + const formatting: JsoncFormatting = { indent: ' ', newline: '\r\n' }; + const result = stringifyJsonc(data, formatting); + expect(result).to.equal('{\r\n "foo": "bar"\r\n}'); + }); + + it('should handle nested objects with proper indentation', () => { + const data = { outer: { inner: 'value' } }; + const formatting: JsoncFormatting = { indent: ' ', newline: '\n' }; + const result = stringifyJsonc(data, formatting); + expect(result).to.equal('{\n "outer": {\n "inner": "value"\n }\n}'); + }); + + it('should handle arrays with proper formatting', () => { + const data = { items: [1, 2, 3] }; + const formatting: JsoncFormatting = { indent: ' ', newline: '\n' }; + const result = stringifyJsonc(data, formatting); + expect(result).to.include('[\n 1,\n 2,\n 3\n ]'); + }); + }); + + describe('updateJsoncPreservingFormatting', () => { + it('should preserve 2-space indentation when updating', () => { + const original = `{ + "foo": "bar", + "baz": 123 +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.foo = 'updated'; + return data; + }); + expect(updated).to.include(' "foo": "updated"'); + expect(updated).to.include(' "baz": 123'); + }); + + it('should preserve 4-space indentation when updating', () => { + const original = `{ + "foo": "bar" +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.foo = 'updated'; + return data; + }); + expect(updated).to.include(' "foo": "updated"'); + }); + + it('should preserve tab indentation when updating', () => { + const original = `{ +\t"foo": "bar" +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.foo = 'updated'; + return data; + }); + expect(updated).to.include('\t"foo": "updated"'); + }); + + it('should preserve LF newlines when updating', () => { + const original = '{\n "foo": "bar"\n}'; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.foo = 'updated'; + return data; + }); + expect(updated).to.include('\n'); + expect(updated).to.not.include('\r\n'); + }); + + it('should preserve CRLF newlines when updating', () => { + const original = '{\r\n "foo": "bar"\r\n}'; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.foo = 'updated'; + return data; + }); + expect(updated).to.equal('{\r\n "foo": "updated"\r\n}'); + }); + + it('should preserve comments when updating', () => { + const original = `{ + // Important comment + "foo": "bar", + /* Another comment */ + "baz": 123 +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.foo = 'updated'; + return data; + }); + expect(updated).to.include('// Important comment'); + expect(updated).to.include('/* Another comment */'); + expect(updated).to.include('"foo": "updated"'); + }); + + it('should handle updating nested objects', () => { + const original = `{ + "outer": { + "inner": "value" + } +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.outer.inner = 'updated'; + return data; + }); + expect(updated).to.include('"inner": "updated"'); + }); + + it('should handle updating arrays', () => { + const original = `{ + "items": [1, 2, 3] +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.items.push(4); + return data; + }); + expect(updated).to.include('4'); + const parsed = JSON.parse(updated.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, '')); + expect(parsed.items).to.deep.equal([1, 2, 3, 4]); + }); + + it('should handle adding new fields', () => { + const original = `{ + "foo": "bar" +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.newField = 'newValue'; + return data; + }); + expect(updated).to.include('"newField": "newValue"'); + expect(updated).to.include('"foo": "bar"'); + }); + + it('should handle removing fields', () => { + const original = `{ + "foo": "bar", + "baz": 123, + "qux": true +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + delete data.baz; + return data; + }); + expect(updated).to.not.include('"baz"'); + expect(updated).to.include('"foo": "bar"'); + expect(updated).to.include('"qux": true'); + }); + + it('should preserve formatting with complex nested structure', () => { + const original = `{ + // Top-level comment + "config": { + "nested": { + "deep": "value" + }, + /* Inline comment */ + "array": [ + 1, + 2, + 3 + ] + }, + "version": "1.0.0" +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.config.nested.deep = 'updated'; + data.config.array.push(4); + return data; + }); + expect(updated).to.include('// Top-level comment'); + expect(updated).to.include('/* Inline comment */'); + expect(updated).to.include('"deep": "updated"'); + expect(updated).to.include(' "config"'); + }); + + it('should preserve formatting when no changes are made', () => { + const original = `{ + // Comment + "foo": "bar", + "baz": 123 +}`; + const updated = updateJsoncPreservingFormatting(original, (data) => data); + expect(updated).to.include('// Comment'); + expect(updated).to.include(' "foo": "bar"'); + expect(updated).to.include(' "baz": 123'); + }); + + it('should handle mixed indentation levels in nested structures', () => { + const original = `{ + "level1": { + "level2": { + "level3": "value" + } + } +}`; + const updated = updateJsoncPreservingFormatting(original, (data: any) => { + data.level1.level2.level3 = 'updated'; + data.level1.newProp = 'new'; + return data; + }); + expect(updated).to.include('"level3": "updated"'); + expect(updated).to.include('"newProp": "new"'); + }); + }); +}); diff --git a/scopes/toolbox/json/jsonc-utils/jsonc-utils.ts b/scopes/toolbox/json/jsonc-utils/jsonc-utils.ts new file mode 100644 index 000000000000..bc56f59a06f2 --- /dev/null +++ b/scopes/toolbox/json/jsonc-utils/jsonc-utils.ts @@ -0,0 +1,60 @@ +import detectIndent from 'detect-indent'; +import detectNewline from 'detect-newline'; +import { parse, stringify } from 'comment-json'; + +export type JsoncFormatting = { + indent: string; + newline: string; +}; + +/** + * Detects the indentation and newline style from a JSONC file content. + * Defaults to 2 spaces and LF if not detected. + */ +export function detectJsoncFormatting(content: string): JsoncFormatting { + const indent = detectIndent(content).indent || ' '; + const newline = detectNewline(content) || '\n'; + return { indent, newline }; +} + +/** + * Parses JSONC content and detects its formatting. + */ +export function parseJsoncWithFormatting(content: string): { data: any; formatting: JsoncFormatting } { + const data = parse(content); + const formatting = detectJsoncFormatting(content); + return { data, formatting }; +} + +/** + * Stringifies a JSONC object with the given formatting. + * Handles CRLF newlines by replacing LF with CRLF after stringification. + */ +export function stringifyJsonc(data: any, formatting: JsoncFormatting): string { + const stringified = stringify(data, null, formatting.indent); + return formatting.newline === '\r\n' ? stringified.replace(/\n/g, '\r\n') : stringified; +} + +/** + * Updates a JSONC file content while preserving its original formatting (indentation, newlines, and comments). + * + * @param originalContent - The original JSONC file content + * @param updateFn - Function that receives the parsed data and returns the updated data + * @returns The stringified updated content with preserved formatting + * + * @example + * ```typescript + * const updatedContent = updateJsoncPreservingFormatting(originalContent, (data) => { + * data.someField = 'new value'; + * return data; + * }); + * ``` + */ +export function updateJsoncPreservingFormatting( + originalContent: string, + updateFn: (data: T) => T +): string { + const { data, formatting } = parseJsoncWithFormatting(originalContent); + const updatedData = updateFn(data); + return stringifyJsonc(updatedData, formatting); +} diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index 0e459d480d90..31a9de70e67e 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -11,6 +11,7 @@ import yesno from 'yesno'; import type { Workspace } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; import { compact, mapValues, omit, uniq, intersection, groupBy } from 'lodash'; +import semver from 'semver'; import type { ProjectManifest } from '@pnpm/types'; import type { GenerateResult, GeneratorMain } from '@teambit/generator'; import { GeneratorAspect } from '@teambit/generator'; @@ -22,6 +23,7 @@ import { VariantsAspect } from '@teambit/variants'; import type { Component } from '@teambit/component'; import { ComponentID, ComponentMap } from '@teambit/component'; import { PackageJsonFile } from '@teambit/component.sources'; +import { updateJsoncPreservingFormatting } from '@teambit/toolbox.json.jsonc-utils'; import { createLinks } from '@teambit/dependencies.fs.linked-dependencies'; import pMapSeries from 'p-map-series'; import type { Harmony, SlotRegistry } from '@teambit/harmony'; @@ -29,12 +31,13 @@ import { Slot } from '@teambit/harmony'; import { type DependenciesGraph } from '@teambit/objects'; import type { CodemodResult, NodeModulesLinksResult } from '@teambit/workspace.modules.node-modules-linker'; import { linkToNodeModulesWithCodemod } from '@teambit/workspace.modules.node-modules-linker'; -import type { EnvsMain } from '@teambit/envs'; +import type { EnvJsonc, EnvsMain } from '@teambit/envs'; import { EnvsAspect } from '@teambit/envs'; import type { IpcEventsMain } from '@teambit/ipc-events'; import { IpcEventsAspect } from '@teambit/ipc-events'; import { IssuesClasses } from '@teambit/component-issues'; import type { + EnvPolicyEnvJsoncConfigObject, GetComponentManifestsOptions, WorkspaceDependencyLifecycleType, DependencyResolverMain, @@ -1008,6 +1011,61 @@ export class InstallMain { ); } + /** + * Update env.jsonc policy files for environment components based on a list of outdated packages. + * @param outdatedPkgs - List of outdated packages. + */ + async updateEnvJsoncPolicies(outdatedPkgs: MergedOutdatedPkg[]): Promise { + // Group packages by componentId, skipping those without one + const updatesByComponentId = new Map(); + for (const pkg of outdatedPkgs) { + if (!pkg.componentId) continue; + const key = pkg.componentId.toString(); + const existing = updatesByComponentId.get(key); + if (existing) { + existing.push(pkg); + } else { + updatesByComponentId.set(key, [pkg]); + } + } + + await Promise.all(Array.from(updatesByComponentId.values()).map(async (pkgs) => { + const componentId = pkgs[0].componentId!; + const component = await this.workspace.get(componentId); + const envJsoncFile = component.filesystem.files.find((file) => file.relative === 'env.jsonc'); + if (!envJsoncFile) return; + + const envJsoncContent = envJsoncFile.contents.toString(); + const updatedContent = updateJsoncPreservingFormatting(envJsoncContent, (envJsonc: EnvJsonc): EnvJsonc => { + pkgs.forEach((pkg) => { + let field: keyof EnvPolicyEnvJsoncConfigObject | undefined; + if (pkg.targetField === 'devDependencies') field = 'dev'; + if (pkg.targetField === 'dependencies') field = 'runtime'; + if (pkg.targetField === 'peerDependencies') field = 'peers'; + + if (!field) return; + + const deps = envJsonc.policy?.[field]; + if (!Array.isArray(deps)) return; + + const depEntry = deps.find(({ name }) => name === pkg.name); + if (depEntry) { + depEntry.version = pkg.latestRange; + if (field === 'peers' && depEntry.supportedRange) { + if (!semver.intersects(pkg.latestRange, depEntry.supportedRange)) { + depEntry.supportedRange = `${depEntry.supportedRange} || ${pkg.latestRange}`; + } + } + } + }); + return envJsonc; + }); + + const absPath = path.join(this.workspace.componentDir(component.id), 'env.jsonc'); + await fs.writeFile(absPath, updatedContent); + })); + } + private async _getAllUsedEnvIds(): Promise { const envs = new Map(); const components = await this.workspace.list(); @@ -1039,11 +1097,11 @@ export class InstallMain { patterns: options.patterns, forceVersionBump: options.forceVersionBump, }); - if (outdatedPkgs == null) { + if (!outdatedPkgs || !outdatedPkgs.length) { this.logger.consoleFailure('No dependencies found that match the patterns'); return null; } - let outdatedPkgsToUpdate!: MergedOutdatedPkg[]; + let outdatedPkgsToUpdate: MergedOutdatedPkg[]; if (options.all) { outdatedPkgsToUpdate = outdatedPkgs; } else { @@ -1060,11 +1118,25 @@ export class InstallMain { } return null; } - const { updatedVariants, updatedComponents } = this.dependencyResolver.applyUpdates(outdatedPkgsToUpdate, { + + const envJsoncUpdates: MergedOutdatedPkg[] = []; + const policiesUpdates: MergedOutdatedPkg[] = []; + for (const outdatedPkg of outdatedPkgsToUpdate) { + if (outdatedPkg.source === 'env-jsonc') { + envJsoncUpdates.push(outdatedPkg); + } else { + policiesUpdates.push(outdatedPkg); + } + } + + const { updatedVariants, updatedComponents } = this.dependencyResolver.applyUpdates(policiesUpdates, { variantPoliciesByPatterns, }); - await this._updateVariantsPolicies(updatedVariants); - await this._updateComponentsConfig(updatedComponents); + await Promise.all([ + this.updateEnvJsoncPolicies(envJsoncUpdates), + this._updateVariantsPolicies(updatedVariants), + this._updateComponentsConfig(updatedComponents), + ]); await this.workspace._reloadConsumer(); return this._installModules({ dedupe: true }); } diff --git a/scopes/workspace/install/pick-outdated-pkgs.spec.ts b/scopes/workspace/install/pick-outdated-pkgs.spec.ts index fabe1301c643..d0e1e5c3caab 100644 --- a/scopes/workspace/install/pick-outdated-pkgs.spec.ts +++ b/scopes/workspace/install/pick-outdated-pkgs.spec.ts @@ -64,6 +64,30 @@ describe('makeOutdatedPkgChoices', () => { // eslint-disable-next-line @typescript-eslint/no-use-before-define expect(stripped).to.deep.equal(contextOrders); }); + it('should render env-jsonc packages with correct context', () => { + const choices = makeOutdatedPkgChoices([ + { + name: 'react', + currentRange: '^18.0.0', + latestRange: '^18.2.0', + source: 'env-jsonc', + componentId: ComponentID.fromString('teambit.react/react-env'), + targetField: 'peerDependencies', + }, + { + name: 'typescript', + currentRange: '~5.0.0', + latestRange: '~5.3.0', + source: 'env-jsonc', + componentId: ComponentID.fromString('teambit.typescript/typescript'), + targetField: 'devDependencies', + }, + ]); + // Removing the ansi chars for better work on bit build on ci + const stripped = stripAnsiFromChoices(choices); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + expect(stripped).to.deep.equal(envJsoncContextOrders); + }); }); function stripAnsiFromChoices(choices) { @@ -81,7 +105,7 @@ const orderedChoices = [ choices: [ { message: 'foo (runtime) 1.0.0 ❯ 2.0.0 ', - name: 'foo', + name: 'foo-0', value: { currentRange: '1.0.0', latestRange: '2.0.0', @@ -92,7 +116,7 @@ const orderedChoices = [ }, { message: 'qar (runtime) 1.0.0 ❯ 1.1.0 ', - name: 'qar', + name: 'qar-1', value: { currentRange: '1.0.0', latestRange: '1.1.0', @@ -103,7 +127,7 @@ const orderedChoices = [ }, { message: 'zoo (dev) 1.0.0 ❯ 1.1.0 ', - name: 'zoo', + name: 'zoo-2', value: { currentRange: '1.0.0', latestRange: '1.1.0', @@ -114,7 +138,7 @@ const orderedChoices = [ }, { message: 'bar (peer) 1.0.0 ❯ 1.1.0 ', - name: 'bar', + name: 'bar-3', value: { currentRange: '1.0.0', latestRange: '1.1.0', @@ -133,7 +157,7 @@ const contextOrders = [ choices: [ { message: 'foo (runtime) 1.0.0 ❯ 2.0.0 ', - name: 'foo', + name: 'foo-0', value: { componentId: ComponentID.fromString('scope/comp1'), currentRange: '1.0.0', @@ -150,7 +174,7 @@ const contextOrders = [ choices: [ { message: 'bar (peer) 1.0.0 ❯ 1.1.0 ', - name: 'bar', + name: 'bar-1', value: { currentRange: '1.0.0', latestRange: '1.1.0', @@ -164,3 +188,40 @@ const contextOrders = [ message: '{comp2} (variant)', }, ]; + +const envJsoncContextOrders = [ + { + choices: [ + { + message: 'typescript (dev) ~5.0.0 ❯ ~5.3.0 ', + name: 'typescript-0', + value: { + componentId: ComponentID.fromString('teambit.typescript/typescript'), + currentRange: '~5.0.0', + latestRange: '~5.3.0', + name: 'typescript', + source: 'env-jsonc', + targetField: 'devDependencies', + }, + }, + ], + message: 'teambit.typescript/typescript (env.jsonc)', + }, + { + choices: [ + { + message: 'react (peer) ^18.0.0 ❯ ^18.2.0 ', + name: 'react-1', + value: { + componentId: ComponentID.fromString('teambit.react/react-env'), + currentRange: '^18.0.0', + latestRange: '^18.2.0', + name: 'react', + source: 'env-jsonc', + targetField: 'peerDependencies', + }, + }, + ], + message: 'teambit.react/react-env (env.jsonc)', + }, +]; diff --git a/scopes/workspace/install/pick-outdated-pkgs.ts b/scopes/workspace/install/pick-outdated-pkgs.ts index 64ca29d3831b..e88395aa6651 100644 --- a/scopes/workspace/install/pick-outdated-pkgs.ts +++ b/scopes/workspace/install/pick-outdated-pkgs.ts @@ -80,9 +80,11 @@ export function makeOutdatedPkgChoices(outdatedPkgs: MergedOutdatedPkg[]) { if (!groupedChoices[context]) { groupedChoices[context] = []; } + // Make the name unique by combining package name with index + // This prevents issues when the same package appears in multiple contexts groupedChoices[context].push({ message: renderedTable[index], - name: outdatedPkg.name, + name: `${outdatedPkg.name}-${index}`, value: outdatedPkg, }); }); @@ -97,6 +99,9 @@ function renderContext(outdatedPkg: MergedOutdatedPkg) { if (outdatedPkg.variantPattern) { return `${outdatedPkg.variantPattern} (variant)`; } + if (outdatedPkg.source === 'env-jsonc' && outdatedPkg.componentId) { + return `${outdatedPkg.componentId} (env.jsonc)`; + } if (outdatedPkg.componentId) { return `${outdatedPkg.componentId} (component)`; }