diff --git a/.bumpy/include-extra-packages.md b/.bumpy/include-extra-packages.md new file mode 100644 index 0000000..56e4e83 --- /dev/null +++ b/.bumpy/include-extra-packages.md @@ -0,0 +1,5 @@ +--- +'fledgling': minor +--- + +New `include` config option: list exact package names that have no package.json in the workspace — e.g. per-platform native binary packages published as optional dependencies — and fledgling treats them like discovered packages (claimed, trusted, synced, tab-completed). They're npm-only; `fledgling jsr` skips them. diff --git a/README.md b/README.md index 2615612..9cc8b52 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,28 @@ internal-but-published things you never want it to claim or manage trust for — Ignored packages are invisible to fledgling: they're left out of `add`, `sync`, `"*"` globs, and tab completion. +### Extra packages (native binaries, etc.) + +Some packages are published from a repo without having their own `package.json` in the +workspace — the classic case is per-platform native binary packages shipped as +`optionalDependencies` and generated at build time. Add their **exact names** (no globs) +to an `"include"` list and fledgling treats them like any discovered package — claimed, +trusted, synced, and tab-completed: + +```jsonc +{ + "fledgling": { + "include": [ + "@scope/my-tool-darwin-arm64", + "@scope/my-tool-linux-x64-gnu" + ] + } +} +``` + +Since these have no manifest of their own, their placeholder claims carry no metadata +beyond the name. They're npm-only: `fledgling jsr` skips them. + ### Defaults | Option | Default | Notes | diff --git a/src/commands/add.command.ts b/src/commands/add.command.ts index 25f773f..d58786c 100644 --- a/src/commands/add.command.ts +++ b/src/commands/add.command.ts @@ -1,7 +1,7 @@ import pc from 'picocolors'; -import { findWorkspaceRoot, discoverPackages, detectRepo } from '../workspace.js'; +import { findWorkspaceRoot, detectRepo } from '../workspace.js'; import { npmAuthCheck, checkNpmVersion } from '../npm.js'; -import { resolveTargets, processTarget, summarize, validateTrustSettings, buildSettings, applyIgnore, type Reporter } from '../core.js'; +import { resolveTargets, processTarget, summarize, validateTrustSettings, buildSettings, collectPackages, type Reporter } from '../core.js'; import { loadConfig } from '../config.js'; import { twoFactorDisabledWarning } from '../ui.js'; import { runWizard } from '../interactive.js'; @@ -11,7 +11,7 @@ import { npmArgs, selectorsOf, type Ctx } from '../args.js'; function runPlain(values: Record, selectors: string[]): number { const root = findWorkspaceRoot(); const config = loadConfig(root); - const discovered = applyIgnore(discoverPackages(root), config.ignore); + const discovered = collectPackages(root, config); const repo = values.repo ?? detectRepo(root)?.slug; const resolved = resolveTargets(discovered, selectors, !!values.new, root); diff --git a/src/commands/jsr.command.ts b/src/commands/jsr.command.ts index 0214881..31b6250 100644 --- a/src/commands/jsr.command.ts +++ b/src/commands/jsr.command.ts @@ -62,6 +62,8 @@ export async function runJsr(values: Record, selectors: string[]): const spin = hatchSpinner(); spin.start('Scanning workspace'); + // `config.include` extras are npm-only: with no package.json in the workspace there's + // nothing to scaffold a jsr.json next to (and native binaries don't belong on JSR). const discovered = applyIgnore(discoverPackages(root), config.ignore); const repoInfo = detectRepo(root); spin.stop(`Found ${pc.bold(String(discovered.length))} package(s)${repoInfo ? ` · ${pc.dim(repoInfo.slug)}` : ''}`); diff --git a/src/commands/sync.command.ts b/src/commands/sync.command.ts index e0cb95d..45704b2 100644 --- a/src/commands/sync.command.ts +++ b/src/commands/sync.command.ts @@ -1,7 +1,7 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; import { setTimeout as sleep } from 'node:timers/promises'; -import { findWorkspaceRoot, discoverPackages, detectRepo, type Pkg } from '../workspace.js'; +import { findWorkspaceRoot, detectRepo, type Pkg } from '../workspace.js'; import { npmAuthCheck, checkNpmVersion, listTrust, configureTrust, revokeTrust, warmNpmAuth, publishedNames } from '../npm.js'; import { npmArgs, selectorsOf, type Ctx } from '../args.js'; import { @@ -12,7 +12,7 @@ import { trustMatches, describeTrustDiff, describeConfig, - applyIgnore, + collectPackages, trustReadHint, } from '../core.js'; import { loadConfig } from '../config.js'; @@ -42,7 +42,7 @@ export async function runSync(values: Record, selectors: string[]): // Reports "logged in as…" and warns if 2FA is off (trust writes would 403). reportNpmAuth(auth); - const discovered = applyIgnore(discoverPackages(root), config.ignore); + const discovered = collectPackages(root, config); const resolved = resolveTargets(discovered, selectors, false, root); if (resolved.error) { diff --git a/src/config.ts b/src/config.ts index 3d810e3..b17158d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,12 @@ export interface FledglingConfig { trust?: boolean; /** Package names/globs to exclude from fledgling entirely (besides `"private": true`). */ ignore?: string[]; + /** + * Extra package names to manage that have no package.json in the workspace — + * e.g. per-platform native binary packages published as optional dependencies. + * Exact names (no globs), treated like discovered packages everywhere but JSR. + */ + include?: string[]; provider?: Provider; /** * May the trusted publisher run `npm publish` directly? (default: true). npm always diff --git a/src/core.ts b/src/core.ts index 5b3468b..39439be 100644 --- a/src/core.ts +++ b/src/core.ts @@ -231,10 +231,25 @@ export function applyIgnore(pkgs: Pkg[], ignore?: string[]): Pkg[] { return pkgs.filter(p => !res.some(re => re.test(p.name))); } +/** + * The full package list: workspace discovery, minus `ignore`, plus `include` — extra + * names with no package.json of their own (e.g. per-platform native binary packages + * shipped as optional deps). Extras are synthesized at the workspace root with a bare + * manifest; being explicit, they are not subject to `ignore`. + */ +export function collectPackages(root: string, config: FledglingConfig): Pkg[] { + const pkgs = applyIgnore(discoverPackages(root), config.ignore); + const seen = new Set(pkgs.map(p => p.name)); + for (const name of config.include ?? []) { + if (!seen.has(name)) (seen.add(name), pkgs.push({ name, dir: root, manifest: { name } })); + } + return pkgs; +} + /** Package names in the current workspace — used for tab completion and the wizard. */ export function workspacePackages(): Pkg[] { const root = findWorkspaceRoot(); - return applyIgnore(discoverPackages(root), loadConfig(root).ignore); + return collectPackages(root, loadConfig(root)); } export interface ResolveResult { diff --git a/src/interactive.ts b/src/interactive.ts index 183117d..72bfa66 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -1,13 +1,13 @@ import * as p from '@clack/prompts'; import pc from 'picocolors'; -import { findWorkspaceRoot, discoverPackages, detectRepo, type Pkg } from './workspace.js'; +import { findWorkspaceRoot, detectRepo, type Pkg } from './workspace.js'; import { npmAuthCheck, publishedNames, warmNpmAuth, validatePackageName, isNameAvailable } from './npm.js'; import { resolveTargets, processTarget, summarize, describeConfig, - applyIgnore, + collectPackages, type Settings, type Reporter, type TargetResult, @@ -32,7 +32,7 @@ export async function runWizard(values: Record, selectors: string[] const newClaim = !!values.new; const spin = hatchSpinner(); if (!newClaim) spin.start('Scanning workspace'); - const discovered = applyIgnore(discoverPackages(root), config.ignore); + const discovered = collectPackages(root, config); const repoInfo = detectRepo(root); if (!newClaim) { spin.stop(`Found ${pc.bold(String(discovered.length))} package(s)${repoInfo ? ` · ${pc.dim(repoInfo.slug)}` : ''}`);