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
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
p.log.info(pc.dim("Dry-run mode — no changes will be made"));
}

if (config.npm.autoDetectedReason) {
p.log.info(config.npm.autoDetectedReason);
}

const hookCtx = () => ({ config, version: newVersion, tag: gitTag, changelog, isBeta });
let newVersion = "";
let gitTag = "";
Expand Down
97 changes: 95 additions & 2 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
import { normalizeFlags } from "./config.ts";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { loadConfig, normalizeFlags } from "./config.ts";

describe("normalizeFlags (M2)", () => {
test("undefined → empty array", () => {
Expand Down Expand Up @@ -38,3 +42,92 @@ describe("normalizeFlags (M2)", () => {
expect(normalizeFlags(["", "--no-verify", ""])).toEqual(["--no-verify"]);
});
});

describe("loadConfig npm.cwd auto-detection", () => {
let root: string;

beforeEach(() => {
root = mkdtempSync(resolve(tmpdir(), "shipx-cfg-"));
// Mark as a git repo so any downstream consumers behave; not strictly
// required for loadConfig but keeps it close to real-world use.
execSync("git init -q", { cwd: root });
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

function writePkg(dir: string, pkg: object): void {
mkdirSync(dir, { recursive: true });
writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg));
}

test("publishable root: npm.cwd defaults to root, no auto-detect reason", async () => {
writePkg(root, { name: "foo", bin: "cli.js" });
const cfg = await loadConfig(root);
expect(cfg.npm.cwd).toBe(root);
expect(cfg.npm.autoDetectedReason).toBe("");
});

test('"private": true root: auto-detects single publishable subpackage', async () => {
writePkg(root, { name: "lacy", private: true });
writePkg(resolve(root, "packages/lacy"), { name: "lacy", bin: { lacy: "index.mjs" } });

const cfg = await loadConfig(root);
expect(cfg.npm.cwd).toBe(resolve(root, "packages/lacy"));
expect(cfg.npm.autoDetectedReason).toMatch(/packages\/lacy/);
expect(cfg.npm.targets[0].cwd).toBe(resolve(root, "packages/lacy"));
});

test("auto-detection also points packageJsonPaths + versionSource at the detected subpackage", async () => {
writePkg(root, { name: "lacy", private: true });
writePkg(resolve(root, "packages/lacy"), { name: "lacy", bin: "x.js" });

const cfg = await loadConfig(root);
expect(cfg.versionSource).toBe("packages/lacy/package.json");
// Subpackage first so preflight (which reads paths[0]) sees the right
// bin/files; root second so it still gets a version bump in lockstep.
expect(cfg.packageJsonPaths).toEqual([
"packages/lacy/package.json",
"package.json",
]);
});

test("user-set packageJsonPaths is not overridden by auto-detection", async () => {
writePkg(root, { name: "lacy", private: true });
writePkg(resolve(root, "packages/lacy"), { name: "lacy", bin: "x.js" });
writeFileSync(
resolve(root, ".shipxrc.json"),
JSON.stringify({ packageJsonPaths: ["custom.json"] }),
);

const cfg = await loadConfig(root);
expect(cfg.packageJsonPaths).toEqual(["custom.json"]);
// npm.cwd still auto-detects independently
expect(cfg.npm.cwd).toBe(resolve(root, "packages/lacy"));
});

test("ambiguous root: falls back to root and records the ambiguity", async () => {
writePkg(root, { name: "monorepo", private: true });
writePkg(resolve(root, "packages/a"), { name: "a", main: "a.js" });
writePkg(resolve(root, "packages/b"), { name: "b", main: "b.js" });

const cfg = await loadConfig(root);
expect(cfg.npm.cwd).toBe(root);
expect(cfg.npm.autoDetectedReason).toMatch(/set npm.cwd or npm.targets explicitly/);
});

test("user-set npm.cwd overrides detection and is resolved against root", async () => {
writePkg(root, { name: "lacy", private: true });
writePkg(resolve(root, "packages/lacy"), { name: "lacy", bin: "x.js" });
writePkg(resolve(root, "packages/other"), { name: "other", bin: "y.js" });
writeFileSync(
resolve(root, ".shipxrc.json"),
JSON.stringify({ npm: { cwd: "packages/other" } }),
);

const cfg = await loadConfig(root);
expect(cfg.npm.cwd).toBe(resolve(root, "packages/other"));
expect(cfg.npm.autoDetectedReason).toBe("");
});
});
56 changes: 52 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { detectPublishTarget } from "./detect.ts";
import type { ResolvedConfig, ShipConfig } from "./types.ts";
import { detectDefaultBranch, exec, readJson } from "./utils.ts";

Expand Down Expand Up @@ -53,6 +54,7 @@ const DEFAULTS: Omit<ResolvedConfig, "root"> = {
cwd: "",
access: "public",
targets: [],
autoDetectedReason: "",
},
homebrew: {
tapPath: "",
Expand Down Expand Up @@ -90,6 +92,7 @@ function mergeConfig(base: Omit<ResolvedConfig, "root">, user: ShipConfig): Omit
cwd: user.npm?.cwd ?? base.npm.cwd,
access: user.npm?.access ?? base.npm.access,
targets: [], // resolved in loadConfig after cwd is finalized
autoDetectedReason: "",
},
homebrew: {
...base.homebrew,
Expand Down Expand Up @@ -132,12 +135,57 @@ export async function loadConfig(root: string): Promise<ResolvedConfig> {

const merged = mergeConfig(DEFAULTS, userConfig);

if (!merged.packageJsonPaths.length && existsSync(pkgPath)) {
merged.packageJsonPaths = ["package.json"];
}
const userSetCwd = typeof userConfig.npm?.cwd === "string" && userConfig.npm.cwd.length > 0;
const userSetTargets = (userConfig.npm?.targets?.length ?? 0) > 0;
const userSetPackageJsonPaths = (userConfig.packageJsonPaths?.length ?? 0) > 0;
const userSetVersionSource = typeof userConfig.versionSource === "string" && userConfig.versionSource.length > 0;

// Try auto-detection only when the user gave us no publish-target hint
// (no npm.cwd, no npm.targets). The detector is conservative: it only
// picks a subpackage when the root package.json is clearly unpublishable
// (private, workspace root, or no entry points).
let detectedRelativePath: string | null = null;
if (!merged.npm.cwd) {
merged.npm.cwd = root;
if (!userSetCwd && !userSetTargets) {
const detected = detectPublishTarget(root);
if (detected.target) {
merged.npm.cwd = detected.target.cwd;
merged.npm.autoDetectedReason = `auto-detected npm.cwd → ${detected.target.relativePath} (${detected.reason})`;
detectedRelativePath = detected.target.relativePath;
} else if (detected.ambiguousCandidates && detected.ambiguousCandidates.length > 0) {
// Ambiguous: don't pick one silently. Fall back to root and
// record the ambiguity so preflight can warn loudly.
merged.npm.cwd = root;
merged.npm.autoDetectedReason = `${detected.reason}. Candidates: ${detected.ambiguousCandidates.map((c) => c.relativePath).join(", ")}`;
} else {
merged.npm.cwd = root;
}
} else {
merged.npm.cwd = root;
}
} else {
merged.npm.cwd = resolve(root, merged.npm.cwd);
}

// When auto-detection picked a subpackage as npm.cwd, that same package's
// version field is what the registry will see — so default packageJsonPaths
// and versionSource to it too, unless the user already configured them.
// Without this, shipx would bump root/package.json and publish the
// subpackage with a stale version.
if (detectedRelativePath) {
const subPath = `${detectedRelativePath}/package.json`;
if (!userSetPackageJsonPaths) {
merged.packageJsonPaths = existsSync(pkgPath)
? [subPath, "package.json"]
: [subPath];
}
if (!userSetVersionSource) {
merged.versionSource = subPath;
}
}

if (!merged.packageJsonPaths.length && existsSync(pkgPath)) {
merged.packageJsonPaths = ["package.json"];
}

const userTargets = userConfig.npm?.targets;
Expand Down
135 changes: 135 additions & 0 deletions src/detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { detectPublishTarget, isUnpublishablePackage } from "./detect.ts";

describe("isUnpublishablePackage", () => {
test('returns reason for "private": true', () => {
expect(isUnpublishablePackage({ private: true, bin: "x.js" })).toMatch(/private/);
});

test('returns reason for "workspaces" field', () => {
expect(isUnpublishablePackage({ workspaces: ["packages/*"], bin: "x.js" })).toMatch(/workspace/);
});

test("returns reason when no entry-point fields are present", () => {
expect(isUnpublishablePackage({ name: "foo" })).toMatch(/entry point/);
});

test("returns null when package has bin", () => {
expect(isUnpublishablePackage({ bin: "x.js" })).toBeNull();
});

test("returns null when package has main", () => {
expect(isUnpublishablePackage({ main: "index.js" })).toBeNull();
});

test("returns null when package has exports", () => {
expect(isUnpublishablePackage({ exports: { ".": "./index.js" } })).toBeNull();
});

test("returns null when package has module", () => {
expect(isUnpublishablePackage({ module: "index.mjs" })).toBeNull();
});

test("private takes precedence over having a bin", () => {
expect(isUnpublishablePackage({ private: true, bin: "x.js" })).toMatch(/private/);
});
});

describe("detectPublishTarget", () => {
let root: string;

beforeEach(() => {
root = mkdtempSync(resolve(tmpdir(), "shipx-detect-"));
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

function writePkg(dir: string, pkg: object): void {
mkdirSync(dir, { recursive: true });
writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg));
}

test("returns null when no root package.json", () => {
const result = detectPublishTarget(root);
expect(result.target).toBeNull();
expect(result.reason).toMatch(/no root package.json/);
});

test("returns null when root is publishable", () => {
writePkg(root, { name: "foo", bin: "cli.js" });
const result = detectPublishTarget(root);
expect(result.target).toBeNull();
expect(result.reason).toMatch(/publishable/);
});

test('detects single subpackage when root has "private": true', () => {
writePkg(root, { name: "lacy", private: true });
writePkg(resolve(root, "packages/lacy"), {
name: "lacy",
bin: { lacy: "index.mjs" },
});

const result = detectPublishTarget(root);
expect(result.target).not.toBeNull();
expect(result.target?.relativePath).toBe("packages/lacy");
expect(result.target?.cwd).toBe(resolve(root, "packages/lacy"));
});

test('detects single subpackage when root has "workspaces"', () => {
writePkg(root, { name: "monorepo", workspaces: ["packages/*"] });
writePkg(resolve(root, "packages/core"), { name: "core", main: "index.js" });

const result = detectPublishTarget(root);
expect(result.target?.relativePath).toBe("packages/core");
});

test("detects subpackage when root has no entry points", () => {
writePkg(root, { name: "monorepo" });
writePkg(resolve(root, "apps/cli"), { name: "cli", bin: "cli.js" });

const result = detectPublishTarget(root);
expect(result.target?.relativePath).toBe("apps/cli");
});

test("returns ambiguous when multiple publishable subpackages exist", () => {
writePkg(root, { name: "monorepo", private: true });
writePkg(resolve(root, "packages/a"), { name: "a", main: "a.js" });
writePkg(resolve(root, "packages/b"), { name: "b", main: "b.js" });

const result = detectPublishTarget(root);
expect(result.target).toBeNull();
expect(result.ambiguousCandidates).toHaveLength(2);
expect(result.reason).toMatch(/set npm.cwd or npm.targets explicitly/);
});

test("skips unpublishable subpackages when searching", () => {
writePkg(root, { name: "monorepo", private: true });
writePkg(resolve(root, "packages/published"), { name: "published", bin: "x.js" });
writePkg(resolve(root, "packages/private"), { name: "private", private: true, bin: "y.js" });
writePkg(resolve(root, "packages/empty"), { name: "empty" });

const result = detectPublishTarget(root);
expect(result.target?.relativePath).toBe("packages/published");
});

test("returns null when root is unpublishable and no subpackages found", () => {
writePkg(root, { name: "monorepo", private: true });

const result = detectPublishTarget(root);
expect(result.target).toBeNull();
expect(result.reason).toMatch(/no publishable subpackage found/);
});

test("searches apps/ in addition to packages/", () => {
writePkg(root, { name: "monorepo", workspaces: ["apps/*"] });
writePkg(resolve(root, "apps/web"), { name: "web", main: "index.js" });

const result = detectPublishTarget(root);
expect(result.target?.relativePath).toBe("apps/web");
});
});
Loading