diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 243835d0b..f8aac3307 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -8,6 +8,10 @@ import { getGitTagPrefix, loadConfigurationFromString, validateConfiguration, + setActiveWorkspace, + getActiveWorkspace, + getVersioningPolicy, + WORKSPACES_MIN_VERSION, } from '../config'; import { CraftProjectConfigSchema } from '../schemas/project_config'; import { logger } from '../logger'; @@ -122,6 +126,36 @@ describe('noMerge config', () => { test('fails with invalid noMerge type', () => { expect(() => validateConfiguration({ noMerge: 'yes' })).toThrow(/noMerge/); }); + + test('parses configuration with workspaces', () => { + const data = { + minVersion: '2.27.0', + github: { owner: 'getsentry', repo: 'toolkit' }, + workspaces: { + cli: { + releaseBranchPrefix: 'release/cli', + github: { projectPath: 'cli' }, + targets: [{ name: 'github', tagPrefix: 'cli@' }], + }, + mcp: { + targets: [{ name: 'github', tagPrefix: 'mcp@' }], + }, + }, + }; + + expect(validateConfiguration(data)).toEqual(data); + }); + + test('allows a workspace github override without owner/repo', () => { + const data = { + workspaces: { + cli: { github: { projectPath: 'cli' } }, + }, + }; + + // Workspace github is partial; owner/repo are inherited, not required here. + expect(() => validateConfiguration(data)).not.toThrow(); + }); }); describe('getGitTagPrefix', () => { @@ -190,3 +224,167 @@ describe('getGitTagPrefix', () => { expect(warnSpy).toHaveBeenCalledTimes(1); }); }); + +describe('workspaces', () => { + afterEach(() => { + setActiveWorkspace(undefined); + vi.restoreAllMocks(); + }); + + const WS_CONFIG = [ + `minVersion: ${WORKSPACES_MIN_VERSION}`, + 'github:', + ' owner: getsentry', + ' repo: toolkit', + 'changelog: CHANGELOG.md', + 'workspaces:', + ' cli:', + ' releaseBranchPrefix: release/cli', + ' github:', + ' projectPath: cli', + ' targets:', + ' - name: github', + ' tagPrefix: "cli@"', + ' mcp:', + ' releaseBranchPrefix: release/mcp', + ' versioning:', + ' policy: calver', + ' targets:', + ' - name: github', + ' tagPrefix: "mcp@"', + ].join('\n'); + + test('backward compatible: no workspaces, no selection resolves normally', () => { + setActiveWorkspace(undefined); + loadConfigurationFromString( + ['github:', ' owner: getsentry', ' repo: craft'].join('\n'), + ); + expect(getActiveWorkspace()).toBeUndefined(); + }); + + test('resolves the selected workspace: overrides win, base inherited', () => { + setActiveWorkspace('cli'); + const config = loadConfigurationFromString(WS_CONFIG); + + // Overridden by the workspace. + expect(config.releaseBranchPrefix).toBe('release/cli'); + expect(getGitTagPrefix()).toBe('cli@'); + // github is shallow-merged: owner/repo inherited, projectPath overridden. + expect(config.github).toEqual({ + owner: 'getsentry', + repo: 'toolkit', + projectPath: 'cli', + }); + // Inherited from the top level. + expect(config.changelog).toBe('CHANGELOG.md'); + // `workspaces` is stripped from the resolved config. + expect(config.workspaces).toBeUndefined(); + }); + + test('a different workspace resolves independently', () => { + setActiveWorkspace('mcp'); + const config = loadConfigurationFromString(WS_CONFIG); + expect(config.releaseBranchPrefix).toBe('release/mcp'); + expect(getGitTagPrefix()).toBe('mcp@'); + expect(getVersioningPolicy()).toBe('calver'); + // mcp did not override github.projectPath, so it inherits base github only. + expect(config.github).toEqual({ owner: 'getsentry', repo: 'toolkit' }); + }); + + test('errors when workspaces are defined but none is selected', () => { + setActiveWorkspace(undefined); + expect(() => loadConfigurationFromString(WS_CONFIG)).toThrow( + /defines workspaces; select one/, + ); + }); + + test('errors on an unknown workspace name', () => { + setActiveWorkspace('nope'); + expect(() => loadConfigurationFromString(WS_CONFIG)).toThrow( + /Unknown workspace "nope"/, + ); + }); + + test('errors when a workspace is selected but none are defined', () => { + setActiveWorkspace('cli'); + expect(() => + loadConfigurationFromString( + ['github:', ' owner: getsentry', ' repo: craft'].join('\n'), + ), + ).toThrow(/no "workspaces" are defined/); + }); + + test('errors when minVersion is below the workspaces gate', () => { + setActiveWorkspace('cli'); + const belowGate = WS_CONFIG.replace( + `minVersion: ${WORKSPACES_MIN_VERSION}`, + 'minVersion: 2.14.0', + ); + expect(() => loadConfigurationFromString(belowGate)).toThrow( + new RegExp(`requires minVersion >= ${WORKSPACES_MIN_VERSION}`), + ); + }); + + test('setActiveWorkspace re-resolves against a new selection', () => { + setActiveWorkspace('cli'); + loadConfigurationFromString(WS_CONFIG); + expect(getGitTagPrefix()).toBe('cli@'); + + setActiveWorkspace('mcp'); + loadConfigurationFromString(WS_CONFIG); + expect(getGitTagPrefix()).toBe('mcp@'); + }); + + test('resolved config exposes the workspace targets (publish builder contract)', () => { + // Regression for the parse-time interaction: the `publish` builder reads + // getConfiguration().targets to compute --target choices. With a workspace + // selected up front, this must resolve to that workspace's targets and must + // not throw the "select a workspace" error. + setActiveWorkspace('cli'); + const config = loadConfigurationFromString(WS_CONFIG); + expect(config.targets).toEqual([{ name: 'github', tagPrefix: 'cli@' }]); + }); + + test('does not produce an incomplete github when base has none', () => { + // A workspace that sets only github.projectPath, with NO top-level github, + // must NOT yield a truthy-but-incomplete github object (missing owner/repo) + // — that would make getGlobalGitHubConfig skip its git-remote fallback. + setActiveWorkspace('cli'); + const config = loadConfigurationFromString( + [ + `minVersion: ${WORKSPACES_MIN_VERSION}`, + 'workspaces:', + ' cli:', + ' github:', + ' projectPath: cli', + ' targets:', + ' - name: github', + ' tagPrefix: "cli@"', + ].join('\n'), + ); + // Incomplete github is dropped so git-remote detection can still run. + expect(config.github).toBeUndefined(); + }); + + test('keeps github when workspace override completes owner/repo', () => { + setActiveWorkspace('cli'); + const config = loadConfigurationFromString( + [ + `minVersion: ${WORKSPACES_MIN_VERSION}`, + 'workspaces:', + ' cli:', + ' github:', + ' owner: getsentry', + ' repo: toolkit', + ' projectPath: cli', + ' targets:', + ' - name: github', + ].join('\n'), + ); + expect(config.github).toEqual({ + owner: 'getsentry', + repo: 'toolkit', + projectPath: 'cli', + }); + }); +}); diff --git a/src/commands/publish.ts b/src/commands/publish.ts index a2dfc6e7c..9246e2bf6 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -57,11 +57,22 @@ export const aliases = ['pp', 'publish']; export const description = '🛫 Publish artifacts'; export const builder: CommandBuilder = (yargs: Argv) => { - const definedTargets = getConfiguration().targets || []; - const possibleTargetNames = new Set(getAllTargetNames()); - const allowedTargetNames = definedTargets - .filter(target => target.name && possibleTargetNames.has(target.name)) - .map(BaseTarget.getId); + // Compute the allowed --target choices from the (workspace-resolved) config. + // The active workspace is selected before parsing (see index.ts), so this + // reflects the selected workspace's targets. If the config can't be resolved + // at parse time (e.g. missing/invalid file, or a workspaces config with no + // selection yet during shell completion), fall back to all known target + // names rather than aborting argument parsing. + let allowedTargetNames: string[]; + try { + const definedTargets = getConfiguration().targets || []; + const possibleTargetNames = new Set(getAllTargetNames()); + allowedTargetNames = definedTargets + .filter(target => target.name && possibleTargetNames.has(target.name)) + .map(BaseTarget.getId); + } catch { + allowedTargetNames = getAllTargetNames(); + } return yargs .positional('NEW-VERSION', { diff --git a/src/config.ts b/src/config.ts index dcf2dab01..30b9f6a55 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ import { TargetConfig, ChangelogPolicy, VersioningPolicy, + Workspace, } from './schemas/project_config'; import { ConfigurationError } from './utils/errors'; import { isCompiledGitHubAction } from './utils/detection'; @@ -23,6 +24,7 @@ import { getPackageVersion, parseVersion, versionGreaterOrEqualThan, + SemVer, } from './utils/version'; // Note: We import getTargetByName lazily in expandWorkspaceTargets to avoid // circular dependency: config -> targets -> registry -> utils/registry -> symlink -> version -> config @@ -55,6 +57,176 @@ let _configPathCache: string; */ let _configCache: CraftProjectConfig; +/** + * The minimum craft version required to use the top-level `workspaces` config. + * + * This is the release the workspaces feature ships in. A dev build of that + * release (e.g. `2.27.0-dev.0`) satisfies it via the pre-release relaxation in + * `checkMinimalConfigVersion`. + */ +export const WORKSPACES_MIN_VERSION = '2.27.0'; + +/** + * The name of the currently-selected workspace, or undefined for the default + * (single implicit release unit). Set once via `setActiveWorkspace` from the + * `--workspace` CLI option / `CRAFT_WORKSPACE` env before any config access. + */ +let _activeWorkspaceName: string | undefined; + +/** + * Selects the active workspace for subsequent configuration reads. + * + * Passing `undefined` (or omitting) clears the selection (default behavior). + * Must be called before the configuration is first resolved/cached; it clears + * the caches so a later read re-resolves against the new selection. + */ +export function setActiveWorkspace(name: string | undefined): void { + _activeWorkspaceName = name; + // Invalidate resolved caches so the next read applies the new selection. + _configCache = undefined as unknown as CraftProjectConfig; + _globalGitHubConfigCache = undefined; +} + +/** + * Returns the name of the currently-selected workspace, if any. + */ +export function getActiveWorkspace(): string | undefined { + return _activeWorkspaceName; +} + +/** + * Merges a workspace's overrides onto the top-level (base) config, producing a + * flat `CraftProjectConfig` that the rest of craft consumes unchanged. + * + * Resolution rules: + * - Every release-relevant field defined on the workspace replaces the + * top-level value (shallow override; a workspace either declares a field or + * inherits it wholesale — we do not deep-merge arrays/objects, to keep + * behavior predictable). + * - `github` is shallow-merged (owner/repo/projectPath) so a workspace can + * override just `projectPath` while inheriting owner/repo. + * - `minVersion` and `workspaces` themselves are stripped from the result. + */ +function resolveWorkspaceConfig( + base: CraftProjectConfig, + workspaceName: string, +): CraftProjectConfig { + const workspaces = base.workspaces || {}; + const workspace = workspaces[workspaceName]; + if (!workspace) { + const available = Object.keys(workspaces); + throw new ConfigurationError( + `Unknown workspace "${workspaceName}". ` + + (available.length + ? `Available workspaces: ${available.join(', ')}.` + : 'No workspaces are defined in the configuration.'), + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { workspaces: _ignoredWorkspaces, ...baseWithoutWorkspaces } = base; + + const resolved: CraftProjectConfig = { ...baseWithoutWorkspaces }; + for (const [key, value] of Object.entries(workspace) as [ + keyof Workspace, + unknown, + ][]) { + if (value === undefined) { + continue; + } + if (key === 'github') { + // Shallow-merge github so a workspace can override a single field + // (e.g. just projectPath) while inheriting owner/repo from the base. + const mergedGithub = { + ...(base.github as GitHubGlobalConfig | undefined), + ...(value as Partial), + }; + // Only adopt the merged github if it is complete (has owner + repo). + // Otherwise leave `github` unset so getGlobalGitHubConfig() can still + // fall back to git-remote detection instead of seeing a truthy-but- + // incomplete object and skipping the fallback. (A workspace that only + // sets projectPath without a base github relies on git detection for + // owner/repo, exactly like a top-level config with no github block.) + if (mergedGithub.owner && mergedGithub.repo) { + resolved.github = mergedGithub as GitHubGlobalConfig; + } else { + delete (resolved as { github?: unknown }).github; + } + } else { + (resolved as Record)[key] = value; + } + } + + return resolved; +} + +/** + * Pure check: is `minVersionRaw` (a configured minVersion) >= `requiredVersion`? + * + * Unlike `requiresMinVersion`, this does not read the (possibly not-yet-resolved) + * global configuration, so it is safe to call during config resolution. + */ +function isVersionGteMinVersion( + minVersionRaw: string | undefined, + requiredVersion: string, +): boolean { + if (!minVersionRaw) { + return false; + } + const configuredMinVersion = parseVersion(minVersionRaw); + const required = parseVersion(requiredVersion); + if (!configuredMinVersion || !required) { + return false; + } + return versionGreaterOrEqualThan(configuredMinVersion, required); +} + +/** + * Applies workspace selection + validation to a freshly-parsed config. + * + * - No `workspaces` in config, no active selection → returns the config as-is. + * - `workspaces` present but no selection → error (must pick one explicitly). + * - Selection present but no `workspaces` in config → error. + * - Both present → returns the resolved (merged) config for the selection and + * enforces the `WORKSPACES_MIN_VERSION` gate. + */ +function applyWorkspaceSelection( + config: CraftProjectConfig, +): CraftProjectConfig { + const hasWorkspaces = + !!config.workspaces && Object.keys(config.workspaces).length > 0; + + if (!hasWorkspaces) { + if (_activeWorkspaceName) { + throw new ConfigurationError( + `--workspace "${_activeWorkspaceName}" was given but no "workspaces" ` + + 'are defined in the configuration file.', + ); + } + return config; + } + + // Workspaces are defined: require an explicit selection (no implicit first). + if (!_activeWorkspaceName) { + const available = Object.keys(config.workspaces || {}).join(', '); + throw new ConfigurationError( + 'This configuration defines workspaces; select one with ' + + `--workspace (or the CRAFT_WORKSPACE env var). ` + + `Available workspaces: ${available}.`, + ); + } + + // Gate the feature behind minVersion, mirroring auto-versioning. + if (!isVersionGteMinVersion(config.minVersion, WORKSPACES_MIN_VERSION)) { + throw new ConfigurationError( + `Using "workspaces" requires minVersion >= ${WORKSPACES_MIN_VERSION} ` + + 'in the configuration file.', + ); + } + + return resolveWorkspaceConfig(config, _activeWorkspaceName); +} + /** * Searches the current and parent directories for the configuration file * @@ -155,8 +327,9 @@ export function getConfiguration(clearCache = false): CraftProjectConfig { string, any >; - _configCache = validateConfiguration(rawConfig); - checkMinimalConfigVersion(_configCache); + const parsed = validateConfiguration(rawConfig); + checkMinimalConfigVersion(parsed); + _configCache = applyWorkspaceSelection(parsed); return _configCache; } @@ -172,8 +345,9 @@ export function loadConfigurationFromString( ): CraftProjectConfig { logger.debug('Loading configuration from provided content...'); const rawConfig = load(configContent) as Record; - _configCache = validateConfiguration(rawConfig); - checkMinimalConfigVersion(_configCache); + const parsed = validateConfiguration(rawConfig); + checkMinimalConfigVersion(parsed); + _configCache = applyWorkspaceSelection(parsed); return _configCache; } @@ -206,7 +380,17 @@ function checkMinimalConfigVersion(config: CraftProjectConfig): void { throw new Error(`Cannot parse the current version: "${currentVersionRaw}"`); } - if (versionGreaterOrEqualThan(currentVersion, minVersion)) { + // A dev/pre-release build of X.Y.Z (e.g. "2.27.0-dev.0") already contains the + // features slated for X.Y.Z, so treat it as satisfying a minVersion of up to + // X.Y.Z. Without this, running a local dev build would reject any config + // whose minVersion targets the very release that build is heading toward, + // making it impossible to dogfood a new feature before its release is cut. + // We only relax the CURRENT side (never the configured minVersion side). + const effectiveCurrentVersion: SemVer = currentVersion.pre + ? { ...currentVersion, pre: undefined, build: undefined } + : currentVersion; + + if (versionGreaterOrEqualThan(effectiveCurrentVersion, minVersion)) { logger.debug( `"craft" version is compatible with the minimal version from the configuration file.`, ); @@ -228,21 +412,7 @@ function checkMinimalConfigVersion(config: CraftProjectConfig): void { */ export function requiresMinVersion(requiredVersion: string): boolean { const config = getConfiguration(); - const minVersionRaw = config.minVersion; - - if (!minVersionRaw) { - // If no minVersion is configured, the feature is not available - return false; - } - - const configuredMinVersion = parseVersion(minVersionRaw); - const required = parseVersion(requiredVersion); - - if (!configuredMinVersion || !required) { - return false; - } - - return versionGreaterOrEqualThan(configuredMinVersion, required); + return isVersionGteMinVersion(config.minVersion, requiredVersion); } /** Minimum craft version required for auto-versioning and CalVer */ @@ -280,7 +450,7 @@ export function getVersioningPolicy(): VersioningPolicy { /** * Return the parsed global GitHub configuration */ -let _globalGitHubConfigCache: GitHubGlobalConfig | null; +let _globalGitHubConfigCache: GitHubGlobalConfig | null | undefined; export async function getGlobalGitHubConfig( clearCache = false, ): Promise { @@ -329,12 +499,13 @@ export async function getGlobalGitHubConfig( /** * Gets git tag prefix from configuration * - * Returns the `tagPrefix` of the first `github` target. In a monorepo where - * multiple products are released from separate `.craft.yml` files, each config - * has a single `github` target with its own prefix (e.g. `cli@`, `mcp@`), so - * this resolves unambiguously per release run. If a single config declares - * multiple `github` targets with *differing* prefixes, the configuration is - * ambiguous: the first prefix is returned and a warning is emitted. + * Returns the `tagPrefix` of the first `github` target of the *active* + * configuration. When a `--workspace` is selected, the configuration has + * already been narrowed to that workspace's targets, so this resolves the + * correct per-product prefix. Without workspaces, a repo may still use a + * separate `.craft.yml` per product. If the active config declares multiple + * `github` targets with *differing* prefixes, it is ambiguous: the first prefix + * is returned and a warning is emitted. */ export function getGitTagPrefix(): string { const targets = getConfiguration().targets || []; @@ -348,8 +519,9 @@ export function getGitTagPrefix(): string { logger.warn( 'Multiple "github" targets with different "tagPrefix" values found. ' + `Using "${firstPrefix}". For independently-versioned products in a ` + - 'monorepo, use a separate .craft.yml per product, each with a single ' + - '"github" target and its own "tagPrefix".', + 'monorepo, use a top-level "workspaces" entry per product (or a ' + + 'separate .craft.yml), each with a single "github" target and its own ' + + '"tagPrefix".', ); } diff --git a/src/index.ts b/src/index.ts index 9dfec11ca..ca25418b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,9 +10,14 @@ import { sanitizeDynamicLinkerEnv, warnIfCraftEnvFileExists, } from './utils/env'; -import { envToBool, setGlobals } from './utils/helpers'; +import { + envToBool, + setGlobals, + extractWorkspaceSelection, +} from './utils/helpers'; import { getPackageVersion } from './utils/version'; import { withTracing } from './utils/tracing'; +import { setActiveWorkspace } from './config'; // Commands import * as prepare from './commands/prepare'; @@ -83,6 +88,15 @@ async function main(): Promise { const argv = fixGlobalBooleanFlags(process.argv.slice(2)); + // Resolve the active workspace BEFORE parsing. yargs runs command `builder`s + // (which may read the configuration, e.g. `publish` derives its --target + // choices from config.targets) *before* middleware, so setting the workspace + // via middleware would be too late — the builder would resolve/validate the + // config without a selection and fail. We therefore extract --workspace (or + // CRAFT_WORKSPACE) from the raw argv/env up front, which is the single source + // of truth for the selection (see extractWorkspaceSelection for precedence). + setActiveWorkspace(extractWorkspaceSelection(argv)); + await yargs() .parserConfiguration({ 'boolean-negation': false, @@ -107,6 +121,13 @@ async function main(): Promise { describe: 'Logging level', global: true, }) + .option('workspace', { + type: 'string', + describe: + 'Select a named workspace (release unit) from the configuration. ' + + 'Required when the config defines "workspaces". Env: CRAFT_WORKSPACE', + global: true, + }) .strictCommands() .showHelpOnFail(true) .middleware(setGlobals) diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index d689949f1..e8f02b7f6 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -165,9 +165,19 @@ export const ChangelogConfigSchema = z.union([ ]); /** - * Craft project-specific configuration + * Fields that describe how a single release unit is built and published. + * + * These are shared between the top-level config (the implicit/default release + * unit) and each entry under the top-level `workspaces` map (an explicit, + * independently-versioned release unit). A workspace inherits the top-level + * values as defaults and overrides the fields it declares. + * + * NOTE: this "workspace" (a named, independently-versioned release unit) is a + * different concept from the `npm` target's `workspaces: true` field, which + * discovers npm packages *within* a single target and publishes them all at the + * same version. See docs for the disambiguation. */ -export const CraftProjectConfigSchema = z.object({ +const releaseUnitFields = { github: GitHubGlobalConfigSchema.optional(), targets: z.array(TargetConfigSchema).optional(), preReleaseCommand: z.string().optional(), @@ -175,10 +185,6 @@ export const CraftProjectConfigSchema = z.object({ releaseBranchPrefix: z.string().optional(), changelog: ChangelogConfigSchema.optional(), changelogPolicy: z.enum(['auto', 'simple', 'none']).optional(), - minVersion: z - .string() - .regex(/^\d+\.\d+\.\d+.*$/) - .optional(), requireNames: z.array(z.string()).optional(), statusProvider: BaseStatusProviderSchema.optional(), artifactProvider: BaseArtifactProviderSchema.optional(), @@ -188,6 +194,42 @@ export const CraftProjectConfigSchema = z.object({ * Defaults to true for compiled GitHub Actions (Node.js actions with dist/ folder). */ noMerge: z.boolean().optional(), +} as const; + +/** + * Configuration for a single named workspace (release unit). + * + * A workspace mirrors the release-relevant subset of the top-level config; + * every field is optional and inherits the top-level value when omitted. The + * `github` block is *partial* (all fields optional) so a workspace can override + * just `projectPath` (or `owner`/`repo`) while inheriting the rest from the + * top-level `github`. + */ +export const WorkspaceSchema = z.object({ + ...releaseUnitFields, + github: GitHubGlobalConfigSchema.partial().optional(), +}); + +export type Workspace = z.infer; + +/** + * Craft project-specific configuration + */ +export const CraftProjectConfigSchema = z.object({ + ...releaseUnitFields, + minVersion: z + .string() + .regex(/^\d+\.\d+\.\d+.*$/) + .optional(), + /** + * Named, independently-versioned release units within a single repository. + * + * When present, a release run must select one via `--workspace ` (or + * `CRAFT_WORKSPACE`). The selected workspace's fields override the top-level + * ones. When absent, craft behaves exactly as before (the top-level config is + * the single implicit release unit) — fully backward compatible. + */ + workspaces: z.record(z.string(), WorkspaceSchema).optional(), }); export type CraftProjectConfig = z.infer; diff --git a/src/utils/__tests__/helpers.test.ts b/src/utils/__tests__/helpers.test.ts index 28c357bf3..3ae1507c0 100644 --- a/src/utils/__tests__/helpers.test.ts +++ b/src/utils/__tests__/helpers.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { disableChangelogMentions, envToBool, + extractWorkspaceSelection, MAX_STEP_OUTPUT_BYTES, setGitHubActionsOutput, truncateForOutput, @@ -33,6 +34,76 @@ describe('envToBool', () => expect(envToBool(envVar)).toBe(result), )); +describe('extractWorkspaceSelection', () => { + const NO_ENV = {} as NodeJS.ProcessEnv; + + test('returns undefined when neither flag nor env is set', () => { + expect( + extractWorkspaceSelection(['publish', '1.0.0'], NO_ENV), + ).toBeUndefined(); + }); + + test('reads "--workspace foo"', () => { + expect( + extractWorkspaceSelection(['publish', '--workspace', 'cli'], NO_ENV), + ).toBe('cli'); + }); + + test('reads "--workspace=foo"', () => { + expect(extractWorkspaceSelection(['--workspace=mcp'], NO_ENV)).toBe('mcp'); + }); + + test('CLI flag wins over CRAFT_WORKSPACE env', () => { + expect( + extractWorkspaceSelection(['--workspace', 'cli'], { + CRAFT_WORKSPACE: 'mcp', + } as NodeJS.ProcessEnv), + ).toBe('cli'); + }); + + test('falls back to CRAFT_WORKSPACE when no flag', () => { + expect( + extractWorkspaceSelection(['publish'], { + CRAFT_WORKSPACE: 'mcp', + } as NodeJS.ProcessEnv), + ).toBe('mcp'); + }); + + test('does not consume a following flag as the value (bare --workspace)', () => { + // "--workspace --dry-run" must NOT select "--dry-run"; defer to env. + expect( + extractWorkspaceSelection(['publish', '--workspace', '--dry-run'], { + CRAFT_WORKSPACE: 'mcp', + } as NodeJS.ProcessEnv), + ).toBe('mcp'); + expect( + extractWorkspaceSelection( + ['publish', '--workspace', '--dry-run'], + NO_ENV, + ), + ).toBeUndefined(); + }); + + test('bare --workspace at end of argv falls back to env', () => { + expect( + extractWorkspaceSelection(['publish', '--workspace'], { + CRAFT_WORKSPACE: 'cli', + } as NodeJS.ProcessEnv), + ).toBe('cli'); + expect( + extractWorkspaceSelection(['publish', '--workspace'], NO_ENV), + ).toBeUndefined(); + }); + + test('empty --workspace= falls back to env', () => { + expect( + extractWorkspaceSelection(['--workspace='], { + CRAFT_WORKSPACE: 'cli', + } as NodeJS.ProcessEnv), + ).toBe('cli'); + }); +}); + describe('setGitHubActionsOutput', () => { let outputFile: string; const originalEnv = { ...process.env }; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index d5107f3e7..fdc25e0a8 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -20,6 +20,47 @@ export function envToBool(envVar: unknown): boolean { return !FALSY_ENV_VALUES.has(normalized); } +/** + * Extracts the `--workspace` selection from the raw argv (or the + * `CRAFT_WORKSPACE` env var) before yargs parsing. + * + * This is needed because yargs runs command `builder`s (which may read the + * configuration, e.g. `publish` derives its --target choices from + * config.targets) *before* middleware, so the workspace must be resolved up + * front rather than in a middleware. + * + * Supports `--workspace foo` and `--workspace=foo`. The CLI flag wins over the + * env var, but only when it actually carries a value: a bare `--workspace` with + * no following value (or followed by another flag) is ignored here and left for + * yargs to report, falling back to `CRAFT_WORKSPACE` if set. Returns + * `undefined` when neither yields a value. + * + * @param argv The raw argv array (process.argv.slice(2)) + * @param env The environment to read CRAFT_WORKSPACE from (defaults to process.env) + */ +export function extractWorkspaceSelection( + argv: string[], + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const envWorkspace = env.CRAFT_WORKSPACE || undefined; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--workspace') { + const next = argv[i + 1]; + // Only treat the next token as the value if it isn't another option. + if (next !== undefined && !next.startsWith('-')) { + return next; + } + // Bare/valueless flag: don't consume a flag as the name; defer to env. + return envWorkspace; + } + if (arg.startsWith('--workspace=')) { + return arg.slice('--workspace='.length) || envWorkspace; + } + } + return envWorkspace; +} + export interface GlobalFlags { [flag: string]: unknown; 'dry-run'?: boolean;