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 */
13import { $ } from "bun";
14import { existsSync } from "node:fs";
15import { readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
16import { dirname, join, resolve } from "node:path";
17import { fileURLToPath } from "node:url";
18
19type Channel = "latest" | "beta";
20
21const 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 */
33const INTERNAL_SCOPE = "@executor";
34const 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 */
40const 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
51const 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
63const resolveChannel = (version: string): Channel =>
64 version.includes("-") ? "beta" : "latest";
65
66const 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
84const 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
91type DependencyBlock = Record<string, string>;
92type 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 */
111const 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 */
215const 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
269const 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
325const 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
357await main();