Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"**/dot-prop": "^5.3.0",
"**/kind-of": ">=6.0.3",
"**/node-fetch": "^2.6.7",
"**/yargs-parser": "~18.1.3",
"**/yargs-parser": ">=18.1.3",
"**/parse-url": ">=5.0.3",
"**/ansi-regex": ">=5.0.1 < 6.0.0",
"@jest/reporters/**/strip-ansi": "^6.0.1"
Expand Down Expand Up @@ -63,6 +63,7 @@
"extract-zip": "^2.0.1",
"fast-xml-parser": "^4.2.4",
"git-url-parse": "^16.1.0",
"glob": "^11.0.0",
"is-ci": "^2.0.0",
"jest": "^29.7.0",
"js-yaml": "4.1.1",
Expand Down
117 changes: 117 additions & 0 deletions src/commands/__tests__/targets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { handler } from '../targets';

jest.mock('../../config', () => ({
getConfiguration: jest.fn(),
expandWorkspaceTargets: jest.fn(),
}));

jest.mock('../../targets', () => ({
getAllTargetNames: jest.fn(),
}));

import { getConfiguration, expandWorkspaceTargets } from '../../config';
import { getAllTargetNames } from '../../targets';

describe('targets command', () => {
const mockedGetConfiguration = getConfiguration as jest.Mock;
const mockedExpandWorkspaceTargets = expandWorkspaceTargets as jest.Mock;
const mockedGetAllTargetNames = getAllTargetNames as jest.Mock;
let consoleSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
consoleSpy = jest.spyOn(console, 'log').mockImplementation();
});

afterEach(() => {
consoleSpy.mockRestore();
});

test('lists targets without expansion when no workspaces', async () => {
const targets = [
{ name: 'npm' },
{ name: 'github' },
];

mockedGetConfiguration.mockReturnValue({ targets });
mockedExpandWorkspaceTargets.mockResolvedValue(targets);
mockedGetAllTargetNames.mockReturnValue(['npm', 'github', 'pypi']);

await handler();

expect(mockedExpandWorkspaceTargets).toHaveBeenCalledWith(targets);
expect(consoleSpy).toHaveBeenCalled();
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
expect(output).toEqual(['npm', 'github']);
});

test('lists expanded workspace targets', async () => {
const originalTargets = [
{ name: 'npm', workspaces: true },
{ name: 'github' },
];

const expandedTargets = [
{ name: 'npm', id: '@sentry/core' },
{ name: 'npm', id: '@sentry/browser' },
{ name: 'github' },
];

mockedGetConfiguration.mockReturnValue({ targets: originalTargets });
mockedExpandWorkspaceTargets.mockResolvedValue(expandedTargets);
mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);

await handler();

expect(mockedExpandWorkspaceTargets).toHaveBeenCalledWith(originalTargets);
expect(consoleSpy).toHaveBeenCalled();
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
expect(output).toEqual([
'npm[@sentry/core]',
'npm[@sentry/browser]',
'github',
]);
});

test('filters out unknown target names', async () => {
const targets = [
{ name: 'npm' },
{ name: 'unknown-target' },
{ name: 'github' },
];

mockedGetConfiguration.mockReturnValue({ targets });
mockedExpandWorkspaceTargets.mockResolvedValue(targets);
mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);

await handler();

expect(consoleSpy).toHaveBeenCalled();
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
expect(output).toEqual(['npm', 'github']);
});

test('handles empty targets list', async () => {
mockedGetConfiguration.mockReturnValue({ targets: [] });
mockedExpandWorkspaceTargets.mockResolvedValue([]);
mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);

await handler();

expect(consoleSpy).toHaveBeenCalled();
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
expect(output).toEqual([]);
});

test('handles undefined targets', async () => {
mockedGetConfiguration.mockReturnValue({});
mockedExpandWorkspaceTargets.mockResolvedValue([]);
mockedGetAllTargetNames.mockReturnValue(['npm', 'github']);

await handler();

expect(consoleSpy).toHaveBeenCalled();
const output = JSON.parse(consoleSpy.mock.calls[0][0]);
expect(output).toEqual([]);
});
});
4 changes: 3 additions & 1 deletion src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getArtifactProviderFromConfig,
DEFAULT_RELEASE_BRANCH_NAME,
getGlobalGitHubConfig,
expandWorkspaceTargets,
} from '../config';
import { formatTable, logger } from '../logger';
import { TargetConfig } from '../schemas/project_config';
Expand Down Expand Up @@ -501,7 +502,8 @@ export async function publishMain(argv: PublishOptions): Promise<any> {
}
}

let targetConfigList = config.targets || [];
// Expand any npm workspace targets into individual package targets
let targetConfigList = await expandWorkspaceTargets(config.targets || []);

logger.info(`Looking for publish state file for ${newVersion}...`);
const publishStateFile = `.craft-publish-${newVersion}.json`;
Expand Down
10 changes: 7 additions & 3 deletions src/commands/targets.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { getConfiguration } from '../config';
import { getConfiguration, expandWorkspaceTargets } from '../config';
import { formatJson } from '../utils/strings';
import { getAllTargetNames } from '../targets';
import { BaseTarget } from '../targets/base';

export const command = ['targets'];
export const description = 'List defined targets as JSON array';

export function handler(): any {
const definedTargets = getConfiguration().targets || [];
export async function handler(): Promise<any> {
let definedTargets = getConfiguration().targets || [];

// Expand workspace targets (e.g., npm workspaces)
definedTargets = await expandWorkspaceTargets(definedTargets);

const possibleTargetNames = new Set(getAllTargetNames());
const allowedTargetNames = definedTargets
.filter(target => target.name && possibleTargetNames.has(target.name))
Expand Down
57 changes: 57 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
GitHubGlobalConfig,
ArtifactProviderName,
StatusProviderName,
TargetConfig,
ChangelogPolicy,
} from './schemas/project_config';
import { ConfigurationError } from './utils/errors';
Expand All @@ -20,6 +21,8 @@ import {
parseVersion,
versionGreaterOrEqualThan,
} from './utils/version';
// Note: We import getTargetByName lazily in expandWorkspaceTargets to avoid
// circular dependency: config -> targets -> registry -> utils/registry -> symlink -> version -> config
import { BaseArtifactProvider } from './artifact_providers/base';
import { GitHubArtifactProvider } from './artifact_providers/github';
import { NoneArtifactProvider } from './artifact_providers/none';
Expand Down Expand Up @@ -388,3 +391,57 @@ export function getChangelogConfig(): NormalizedChangelogConfig {
scopeGrouping,
};
}

/**
* Type for target classes that support expansion
*/
interface ExpandableTargetClass {
expand(config: TargetConfig, rootDir: string): Promise<TargetConfig[]>;
}

/**
* Check if a target class has an expand method
*/
function isExpandableTarget(
targetClass: unknown
): targetClass is ExpandableTargetClass {
return (
typeof targetClass === 'function' &&
'expand' in targetClass &&
typeof targetClass.expand === 'function'
);
}

/**
* Expand all expandable targets in the target list
*
* This function takes a list of target configs and expands any targets
* whose target class has an `expand` static method. This allows targets
* to implement their own expansion logic (e.g., npm workspace expansion).
*
* @param targets The original list of target configs
* @returns The expanded list of target configs
*/
export async function expandWorkspaceTargets(
targets: TargetConfig[]
): Promise<TargetConfig[]> {
// Lazy import to avoid circular dependency
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getTargetByName } = require('./targets');

const rootDir = getConfigFileDir() || process.cwd();
const expandedTargets: TargetConfig[] = [];

for (const target of targets) {
const targetClass = getTargetByName(target.name);

if (targetClass && isExpandableTarget(targetClass)) {
const expanded = await targetClass.expand(target, rootDir);
expandedTargets.push(...expanded);
} else {
expandedTargets.push(target);
}
}

return expandedTargets;
}
11 changes: 11 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import consola, {
LogLevel,
} from 'consola';

/** Reporter that writes all output to stderr (so JSON on stdout isn't polluted) */
class StderrReporter extends BasicReporter {
public log(logObj: ConsolaReporterLogObject) {
const output = this.formatLogObj(logObj);
process.stderr.write(output + '\n');
}
}

// Redirect all console output to stderr so it doesn't interfere with JSON output on stdout
consola.setReporters([new StderrReporter()]);

/**
* Format a list as a table
*
Expand Down
26 changes: 26 additions & 0 deletions src/schemas/projectConfig.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,32 @@ const projectConfigJsonSchema = {
properties: {
access: {
type: 'string',
description: 'NPM access level (public or restricted)',
},
checkPackageName: {
type: 'string',
description:
'Package name to check for latest version on the registry',
},
workspaces: {
type: 'boolean',
description:
'Enable workspace discovery to auto-generate npm targets for all workspace packages',
},
includeWorkspaces: {
type: 'string',
description:
'Regex pattern to filter which workspace packages to include',
},
excludeWorkspaces: {
type: 'string',
description:
'Regex pattern to filter which workspace packages to exclude',
},
artifactTemplate: {
type: 'string',
description:
'Template for artifact filenames. Variables: {{name}}, {{simpleName}}, {{version}}',
},
},
additionalProperties: false,
Expand Down
Loading
Loading