History

Ship @executor-js/* beta from monorepo with promise wrapper (#142)

* consolidate @executor-js/core + @executor-js/sdk into one package Undoes the earlier core/sdk package split and ships a single @executor-js/sdk with two subpath entry points, matching the plugin layout: - @executor-js/sdk → promise-based wrapper (default import) - @executor-js/sdk/core → raw Effect-based primitives Internally the packages stay under `@executor/*` (workspace scope). scripts/publish-packages.ts rewrites the scope to `@executor-js/*` at pack time — in both package.json (name, deps, peer deps) and the compiled dist/ artifacts — and resolves `workspace:*` dep specifiers to concrete versions before pack. This keeps the diff vs main small, and `@executor/api` / `@executor/react` (unpublished optional peers) are left alone so source code and the workspace graph are unchanged. The promise wrapper (src/promise.ts + src/promise-executor.ts) lives alongside the Effect sources in packages/core/sdk/src/, so for that directory the only new files are the two wrappers — no renames vs main. tsup builds a dual-entry bundle ({ index: src/promise.ts, core: src/index.ts }) and publishConfig rewrites exports to point at the packed dist/ at publish time. Also: - Add scripts/publish-packages.ts. Works around bun pm pack not substituting publishConfig overrides by rewriting package.json in place during pack and restoring it after. - Remove the separate packages/core/core package and the packages/published/ folder that the earlier stack introduced. - Bump all 8 publishable packages to 0.0.1-beta.4. * expose pluggable storage on promise createExecutor, refresh READMEs - add optional tools/sources/secrets/policies to promise ExecutorConfig, adapting user-supplied promise-shaped impls into Effect services - fix onepassword promise entry to export onepasswordPlugin (was types-only) - rewrite plugin READMEs to lead with async/await, demote Effect to a "Using with Effect" aside rather than an "Effect entry point" lead - bump 8 public packages to 0.0.1-beta.5 * Drop /core subpath; internal imports use bare @executor/sdk Rewires dev exports so internal code treats @executor/sdk (and the 7 plugin packages) as normal packages: bare imports resolve to the Effect-based entry, no more /core subpath gymnastics in the source. - @executor/sdk dev exports: `.` -> src/index.ts (Effect), `./promise` -> src/promise.ts. publishConfig unchanged, so the published package still ships `.` as the promise wrapper and `./core` as the raw Effect primitives. - 7 plugin packages follow the same pattern: dev `.` now points at the plugin's Effect entry, `./promise` added, `./core` dropped. publishConfigs unchanged. - Rewrote 82 `@executor/sdk/core` imports and 9 `@executor/plugin-*/core` imports to bare paths across packages, apps, and READMEs. - examples/promise-sdk imports from `/promise` subpaths since dev bare is now Effect. Published consumers still get the promise API at the bare path via the publishConfig swap at pack time. No changes to scripts/publish-packages.ts or tsup configs — the existing publish-time exports override is what flips dev's bare-Effect into published bare-promise. * fix unused import

Rhys Sullivan committed 7 months ago

Commit 36a79e61 parent 028b28f56 files · +2681 −520Browse files

Showing the changes to scripts (1 of 56 files). Show all

Unified · Split

scripts/publish-packages.ts+357−0
@@ -0,0 +1,357 @@
1+#!/usr/bin/env bun
2+/**
3+ * Publishes the public @executor-js/* workspace packages to npm.
4+ *
5+ * Walks a hard-coded list of publishable package directories, determines the
6+ * dist-tag from the version string (anything containing `-` is treated as beta),
7+ * and packs + publishes each package whose current version is not already on npm.
8+ *
9+ * Invoked from `.github/workflows/release.yml` via the `publish:` input on
10+ * changesets/action after the Version Packages PR has been merged, and locally
11+ * via `bun run release:publish:packages` (or `--dry-run`).
12+ */
13+import { $ } from "bun";
14+import { existsSync } from "node:fs";
15+import { readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
16+import { dirname, join, resolve } from "node:path";
17+import { fileURLToPath } from "node:url";
18+
19+type Channel = "latest" | "beta";
20+
21+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
22+
23+/**
24+ * The repo uses `@executor/*` internally (matching historical package names
25+ * and the workspace graph) but publishes under `@executor-js/*` because the
26+ * short scope was taken on npm. At pack time we rewrite package manifests and
27+ * compiled `dist/` artifacts so the tarball ships with the public scope —
28+ * source code stays unchanged. Only package names in `PUBLIC_PACKAGE_DIRS`
29+ * are rewritten; unpublished peer deps like `@executor/api` / `@executor/react`
30+ * are left alone (they're optional peers and users who don't install them
31+ * just see a warning).
32+ */
33+const INTERNAL_SCOPE = "@executor";
34+const PUBLISHED_SCOPE = "@executor-js";
35+
36+/**
37+ * Workspace-relative paths of the public packages. Kept explicit so a new
38+ * directory under `packages/plugins/` does not accidentally ship to npm.
39+ */
40+const PUBLIC_PACKAGE_DIRS = [
41+ "packages/core/sdk",
42+ "packages/plugins/file-secrets",
43+ "packages/plugins/google-discovery",
44+ "packages/plugins/graphql",
45+ "packages/plugins/keychain",
46+ "packages/plugins/mcp",
47+ "packages/plugins/onepassword",
48+ "packages/plugins/openapi",
49+] as const;
50+
51+const parseArgs = (argv: ReadonlyArray<string>): { dryRun: boolean } => {
52+ let dryRun = false;
53+ for (const arg of argv) {
54+ if (arg === "--dry-run") {
55+ dryRun = true;
56+ continue;
57+ }
58+ throw new Error(`Unknown argument: ${arg}`);
59+ }
60+ return { dryRun };
61+};
62+
63+const resolveChannel = (version: string): Channel =>
64+ version.includes("-") ? "beta" : "latest";
65+
66+const readPackageMeta = async (pkgDir: string) => {
67+ const pkgJsonPath = join(pkgDir, "package.json");
68+ const pkg = await Bun.file(pkgJsonPath).json() as {
69+ name?: string;
70+ version?: string;
71+ private?: boolean;
72+ };
73+
74+ if (!pkg.name || !pkg.version) {
75+ throw new Error(`Missing name/version in ${pkgJsonPath}`);
76+ }
77+ if (pkg.private === true) {
78+ throw new Error(`${pkg.name} is marked private and cannot be published`);
79+ }
80+
81+ return { name: pkg.name, version: pkg.version };
82+};
83+
84+const packageAlreadyPublished = async (name: string, version: string): Promise<boolean> => {
85+ const proc = Bun.spawn(["npm", "view", `${name}@${version}`, "version"], {
86+ stdio: ["ignore", "ignore", "ignore"],
87+ });
88+ return (await proc.exited) === 0;
89+};
90+
91+type DependencyBlock = Record<string, string>;
92+type MutablePackageJson = {
93+ name?: string;
94+ dependencies?: DependencyBlock;
95+ devDependencies?: DependencyBlock;
96+ peerDependencies?: DependencyBlock;
97+ optionalDependencies?: DependencyBlock;
98+ [key: string]: unknown;
99+};
100+
101+/**
102+ * Rewrites the internal `@executor/*` scope to the published `@executor-js/*`
103+ * scope for the given set of names, inside both `package.json` (name +
104+ * dep blocks) and compiled `dist/` artifacts (`.js`, `.d.ts`). Also resolves
105+ * `workspace:*` dep references to concrete versions, since `bun pm pack`
106+ * can't resolve workspace specifiers that no longer match any workspace name
107+ * (after the rename). Returns a restore function that reverts every file we
108+ * touched. Only names in `publishable` are rewritten — `@executor/api` and
109+ * `@executor/react` are left alone because they're unpublished optional peers.
110+ */
111+const applyScopeRename = async (
112+ pkgDir: string,
113+ publishable: ReadonlySet<string>,
114+ publishableVersions: ReadonlyMap<string, string>,
115+): Promise<() => Promise<void>> => {
116+ const toPublished = (internal: string): string =>
117+ internal.replace(`${INTERNAL_SCOPE}/`, `${PUBLISHED_SCOPE}/`);
118+ const toInternal = (published: string): string =>
119+ published.replace(`${PUBLISHED_SCOPE}/`, `${INTERNAL_SCOPE}/`);
120+
121+ const publishableInternalNames = new Set<string>();
122+ for (const published of publishable) publishableInternalNames.add(toInternal(published));
123+
124+ const renameDepBlock = (block: DependencyBlock | undefined): DependencyBlock | undefined => {
125+ if (!block) return block;
126+ const next: DependencyBlock = {};
127+ let mutated = false;
128+ for (const [key, value] of Object.entries(block)) {
129+ if (publishableInternalNames.has(key)) {
130+ const newKey = toPublished(key);
131+ // Resolve workspace:* to the concrete version of the published package.
132+ const newValue = value.startsWith("workspace:")
133+ ? publishableVersions.get(newKey) ?? value
134+ : value;
135+ next[newKey] = newValue;
136+ mutated = true;
137+ } else {
138+ next[key] = value;
139+ }
140+ }
141+ return mutated ? next : block;
142+ };
143+
144+ const snapshots = new Map<string, string>();
145+ const writeIfChanged = async (absPath: string, next: string): Promise<void> => {
146+ const original = await readFile(absPath, "utf8");
147+ if (next === original) return;
148+ snapshots.set(absPath, original);
149+ await writeFile(absPath, next);
150+ };
151+
152+ // 1. package.json — rewrite `name` and every dep block structurally so we
153+ // can also resolve workspace specifiers while we're here.
154+ const pkgJsonPath = join(pkgDir, "package.json");
155+ const pkgRaw = await readFile(pkgJsonPath, "utf8");
156+ const pkg = JSON.parse(pkgRaw) as MutablePackageJson;
157+ if (pkg.name && publishableInternalNames.has(pkg.name)) {
158+ pkg.name = toPublished(pkg.name);
159+ }
160+ pkg.dependencies = renameDepBlock(pkg.dependencies);
161+ pkg.devDependencies = renameDepBlock(pkg.devDependencies);
162+ pkg.peerDependencies = renameDepBlock(pkg.peerDependencies);
163+ pkg.optionalDependencies = renameDepBlock(pkg.optionalDependencies);
164+ const pkgNext = `${JSON.stringify(pkg, null, 2)}\n`;
165+ if (pkgNext !== pkgRaw) {
166+ snapshots.set(pkgJsonPath, pkgRaw);
167+ await writeFile(pkgJsonPath, pkgNext);
168+ }
169+
170+ // 2. dist/**/*.{js,d.ts} — plain string replace of import specifiers, one
171+ // package name at a time. Longest-first avoids partial matches.
172+ const orderedInternal = [...publishableInternalNames].sort((a, b) => b.length - a.length);
173+ const replaceAllInText = (text: string): string => {
174+ let out = text;
175+ for (const internal of orderedInternal) {
176+ out = out.split(internal).join(toPublished(internal));
177+ }
178+ return out;
179+ };
180+ const distDir = join(pkgDir, "dist");
181+ if (existsSync(distDir)) {
182+ const walk = async (dir: string): Promise<void> => {
183+ const entries = await readdir(dir);
184+ for (const entry of entries) {
185+ const abs = join(dir, entry);
186+ const info = await stat(abs);
187+ if (info.isDirectory()) {
188+ await walk(abs);
189+ continue;
190+ }
191+ if (entry.endsWith(".js") || entry.endsWith(".d.ts")) {
192+ const original = await readFile(abs, "utf8");
193+ await writeIfChanged(abs, replaceAllInText(original));
194+ }
195+ }
196+ };
197+ await walk(distDir);
198+ }
199+
200+ return async () => {
201+ for (const [path, original] of snapshots) {
202+ await writeFile(path, original);
203+ }
204+ };
205+};
206+
207+/**
208+ * Applies `publishConfig` field overrides to package.json in place, returning a
209+ * function that restores the original file. `bun pm pack` does not substitute
210+ * `publishConfig.exports` / `publishConfig.main` etc at pack time (npm does,
211+ * but only for a subset of fields and only for `npm pack`), so we rewrite the
212+ * file ourselves so the packed tarball has the correct `exports` pointing at
213+ * `dist/` instead of the dev-time `src/index.ts`.
214+ */
215+const applyPublishConfig = async (pkgDir: string): Promise<() => Promise<void>> => {
216+ const pkgJsonPath = join(pkgDir, "package.json");
217+ const original = await readFile(pkgJsonPath, "utf8");
218+ const parsed = JSON.parse(original) as {
219+ publishConfig?: Record<string, unknown>;
220+ [key: string]: unknown;
221+ };
222+
223+ const publishConfig = parsed.publishConfig;
224+ if (!publishConfig || typeof publishConfig !== "object") {
225+ return async () => {};
226+ }
227+
228+ // Fields we allow publishConfig to override. `access`/`tag`/`registry` are
229+ // real npm publish-time config keys — they must NOT be hoisted into the
230+ // top-level manifest.
231+ const overridable = new Set([
232+ "exports",
233+ "main",
234+ "module",
235+ "types",
236+ "typings",
237+ "bin",
238+ "browser",
239+ "files",
240+ ]);
241+
242+ const nextPublishConfig: Record<string, unknown> = {};
243+ let mutated = false;
244+ for (const [key, value] of Object.entries(publishConfig)) {
245+ if (overridable.has(key)) {
246+ parsed[key] = value;
247+ mutated = true;
248+ } else {
249+ nextPublishConfig[key] = value;
250+ }
251+ }
252+
253+ if (!mutated) {
254+ return async () => {};
255+ }
256+
257+ if (Object.keys(nextPublishConfig).length === 0) {
258+ delete parsed.publishConfig;
259+ } else {
260+ parsed.publishConfig = nextPublishConfig;
261+ }
262+
263+ await writeFile(pkgJsonPath, `${JSON.stringify(parsed, null, 2)}\n`);
264+ return async () => {
265+ await writeFile(pkgJsonPath, original);
266+ };
267+};
268+
269+const publishPackage = async (
270+ pkgDir: string,
271+ channel: Channel,
272+ dryRun: boolean,
273+ publishable: ReadonlySet<string>,
274+ publishableVersions: ReadonlyMap<string, string>,
275+) => {
276+ const { name: internalName, version } = await readPackageMeta(pkgDir);
277+ const publishedName = internalName.replace(`${INTERNAL_SCOPE}/`, `${PUBLISHED_SCOPE}/`);
278+
279+ if (!existsSync(join(pkgDir, "dist"))) {
280+ throw new Error(`Missing dist/ in ${pkgDir}. Did you run 'bun run build:packages'?`);
281+ }
282+
283+ if (await packageAlreadyPublished(publishedName, version)) {
284+ console.log(`[skip] ${publishedName}@${version} already on npm`);
285+ return;
286+ }
287+
288+ console.log(`[publish] ${publishedName}@${version} (${channel})${dryRun ? " [dry-run]" : ""}`);
289+
290+ // Clean any stale tarballs from previous runs so our readdir finds exactly
291+ // the archive produced by the pack below.
292+ const stale = (await readdir(pkgDir)).filter((entry) => entry.endsWith(".tgz"));
293+ for (const entry of stale) {
294+ await rm(join(pkgDir, entry), { force: true });
295+ }
296+
297+ // Order matters: rename the scope first so publishConfig sees the final
298+ // package.json, then apply publishConfig on top. Restore in reverse.
299+ const restoreScope = await applyScopeRename(pkgDir, publishable, publishableVersions);
300+ const restorePublishConfig = await applyPublishConfig(pkgDir);
301+ try {
302+ await $`bun pm pack`.cwd(pkgDir);
303+ } finally {
304+ await restorePublishConfig();
305+ await restoreScope();
306+ }
307+
308+ const produced = (await readdir(pkgDir)).filter((entry) => entry.endsWith(".tgz"));
309+ if (produced.length !== 1) {
310+ throw new Error(`Expected exactly 1 .tgz in ${pkgDir}, found ${produced.length}: ${produced.join(", ")}`);
311+ }
312+ const tarball = produced[0]!;
313+
314+ if (dryRun) {
315+ return;
316+ }
317+
318+ const args = ["publish", tarball, "--access", "public", "--tag", channel];
319+ if (process.env.GITHUB_ACTIONS === "true") {
320+ args.push("--provenance");
321+ }
322+ await $`npm ${args}`.cwd(pkgDir);
323+};
324+
325+const main = async () => {
326+ const { dryRun } = parseArgs(process.argv.slice(2));
327+
328+ // Use the sdk package version as the source of truth for the channel. All
329+ // published packages version together (they're not in the changeset ignore
330+ // list), so they share a release channel.
331+ const sdkMeta = await readPackageMeta(join(repoRoot, "packages/core/sdk"));
332+ const channel = resolveChannel(sdkMeta.version);
333+ console.log(`Publishing ${PUBLISHED_SCOPE} packages (${channel})${dryRun ? " [dry-run]" : ""}`);
334+
335+ await $`bun run build:packages`.cwd(repoRoot);
336+
337+ // Snapshot the internal package names and versions up front so
338+ // applyScopeRename knows (a) which `@executor/*` references to rewrite
339+ // (vs. unpublished peer deps like `@executor/api` that should be left
340+ // alone) and (b) how to resolve `workspace:*` dep specifiers after the
341+ // rename — `bun pm pack` can no longer resolve them itself once the name
342+ // doesn't match any workspace.
343+ const publishable = new Set<string>();
344+ const publishableVersions = new Map<string, string>();
345+ for (const relDir of PUBLIC_PACKAGE_DIRS) {
346+ const pkg = await readPackageMeta(join(repoRoot, relDir));
347+ const publishedName = pkg.name.replace(`${INTERNAL_SCOPE}/`, `${PUBLISHED_SCOPE}/`);
348+ publishable.add(publishedName);
349+ publishableVersions.set(publishedName, pkg.version);
350+ }
351+
352+ for (const relDir of PUBLIC_PACKAGE_DIRS) {
353+ await publishPackage(join(repoRoot, relDir), channel, dryRun, publishable, publishableVersions);
354+ }
355+};
356+
357+await main();