Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .bitmap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions e2e/harmony/dependencies/env-jsonc-policies.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import multimatch from 'multimatch';
import { isSnap } from '@teambit/component-version';
import { BitError } from '@teambit/bit-error';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand All @@ -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<string, VariantPolicyConfigObject>;
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
Expand All @@ -1542,12 +1546,48 @@ 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;

const envJsonc = parse(envJsoncFile.contents.toString()) as EnvJsonc;
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: {
Expand Down Expand Up @@ -1618,9 +1658,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<Array<{ name: string; currentRange: string; latestRange: string } & T>> {
this.logger.setStatusLine('checking the latest versions of dependencies');
const resolver = await this.getVersionResolver();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { EnvPolicy, EnvPolicyConfigObject } from './env-policy';
export { EnvPolicy, EnvPolicyConfigObject, EnvJsoncPolicyEntry } from './env-policy';
7 changes: 7 additions & 0 deletions scopes/toolbox/json/jsonc-utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
detectJsoncFormatting,
parseJsoncWithFormatting,
stringifyJsonc,
updateJsoncPreservingFormatting,
type JsoncFormatting,
} from './jsonc-utils';
167 changes: 167 additions & 0 deletions scopes/toolbox/json/jsonc-utils/jsonc-utils.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
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);
}
```

### Updating workspace.jsonc

Preserve team preferences for indentation when updating workspace config:

```ts
import { updateJsoncPreservingFormatting } from '@teambit/toolbox.json.jsonc-utils';

const updatedWorkspace = updateJsoncPreservingFormatting(
workspaceContent,
(config) => {
config['teambit.workspace/workspace'].defaultScope = 'my-org.my-scope';
return config;
}
);
```

### 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);
```

## Real-World Example

This is exactly how Bit's `bit update` command preserves your env.jsonc formatting when updating dependencies:

```ts
async updateEnvJsoncPolicies(outdatedPkgs) {
await pMapSeries(components, async (component) => {
const envJsoncFile = component.filesystem.files.find(
(file) => file.relative === 'env.jsonc'
);
if (!envJsoncFile) return;

const envJsoncContent = envJsoncFile.contents.toString();
const updatedContent = updateJsoncPreservingFormatting(
envJsoncContent,
(envJsonc) => {
// Update dependency versions
const dep = envJsonc.policy?.runtime?.find(
(d) => d.name === pkg.name
);
if (dep) {
dep.version = pkg.latestRange;
}
return envJsonc;
}
);

await fs.writeFile(absPath, updatedContent);
});
}
```

## Type Definitions

```ts
export type JsoncFormatting = {
indent: string;
newline: string;
};
```

## Why Preserve Formatting?

When working in teams, developers often have different preferences for code formatting:
- Some prefer 2 spaces, others 4 spaces or tabs
- Windows users might have CRLF line endings
- Comments provide important context

By preserving the original formatting, you avoid:
- ❌ Unnecessary git diffs
- ❌ Formatting debates in code reviews
- ❌ Lost comments and documentation
- ❌ Breaking team conventions

Instead, you get:
- ✅ Clean, minimal diffs showing only actual changes
- ✅ Preserved comments and documentation
- ✅ Respect for team and individual preferences
- ✅ Better collaboration
Loading