@bastani/atomic 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { join, sep } from "path";
17
- import { readdir, rm } from "fs/promises";
17
+ import { lstat, readdir, rm, symlink, unlink } from "fs/promises";
18
18
  import { homedir } from "os";
19
19
  import {
20
20
  copyDir,
@@ -46,6 +46,41 @@ function packageRoot(): string {
46
46
  return join(import.meta.dir, "..", "..", "..");
47
47
  }
48
48
 
49
+ /**
50
+ * Safely remove a symlink or junction before re-creating it.
51
+ *
52
+ * On Windows, Bun's `rm({ recursive: true })` follows NTFS junctions and
53
+ * can delete the **target directory's contents** (oven-sh/bun#27233).
54
+ * `unlink` is safe: it opens with `FILE_FLAG_OPEN_REPARSE_POINT` and
55
+ * removes only the link entry, never following it.
56
+ *
57
+ * Falls back to `rm` only when the path is a real directory (not a link),
58
+ * which can happen if a previous version created the path as a plain copy
59
+ * instead of a symlink.
60
+ */
61
+ async function removeLinkOrDir(path: string): Promise<void> {
62
+ try {
63
+ const stats = await lstat(path);
64
+ if (stats.isSymbolicLink()) {
65
+ await unlink(path);
66
+ } else if (stats.isDirectory()) {
67
+ await rm(path, { recursive: true, force: true });
68
+ } else {
69
+ await unlink(path);
70
+ }
71
+ } catch (error: unknown) {
72
+ // ENOENT — path doesn't exist; nothing to remove.
73
+ if (
74
+ error instanceof Error &&
75
+ "code" in error &&
76
+ (error as NodeJS.ErrnoException).code === "ENOENT"
77
+ ) {
78
+ return;
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
49
84
  /** Honors ATOMIC_SETTINGS_HOME so tests can point at a temp dir. */
50
85
  function homeRoot(): string {
51
86
  return process.env.ATOMIC_SETTINGS_HOME ?? homedir();
@@ -102,4 +137,104 @@ export async function installGlobalWorkflows(): Promise<void> {
102
137
  await copyDir(src, dest);
103
138
  }
104
139
  }
140
+
141
+ // ── Type-resolution setup for workflow authors ─────────────────────
142
+ //
143
+ // The bundled tsconfig.json uses relative `paths` that only resolve
144
+ // correctly inside the package's own directory tree. Once the files
145
+ // are copied to `~/.atomic/workflows/`, those relative paths break.
146
+ //
147
+ // Strategy: symlink `node_modules/@bastani/atomic` in the destination
148
+ // back to the running package root. TypeScript's standard module
149
+ // resolution then finds `@bastani/atomic/workflows` (and its
150
+ // transitive deps) automatically — no `paths` override needed.
151
+ //
152
+ // If symlink creation fails (permissions, unsupported FS), we fall
153
+ // back to a tsconfig with an absolute `paths` entry pointing at the
154
+ // package's SDK source. Either way the workflow author gets types
155
+ // with zero manual configuration.
156
+ await setupWorkflowTypes(destRoot);
157
+ }
158
+
159
+ /**
160
+ * Wire up TypeScript type resolution for a global workflows directory.
161
+ *
162
+ * Creates a `node_modules/@bastani/atomic` symlink → the installed
163
+ * package root and generates a tsconfig.json that lets standard module
164
+ * resolution do the work. Falls back to absolute `paths` in the
165
+ * tsconfig if symlinking isn't possible.
166
+ */
167
+ export async function setupWorkflowTypes(destRoot: string): Promise<void> {
168
+ const pkgRoot = packageRoot();
169
+ let usedSymlink = false;
170
+
171
+ // 1. Symlink the package itself
172
+ try {
173
+ const scopeDir = join(destRoot, "node_modules", "@bastani");
174
+ await ensureDir(scopeDir);
175
+
176
+ const link = join(scopeDir, "atomic");
177
+ await removeLinkOrDir(link);
178
+
179
+ // Junctions on Windows need no elevated privileges.
180
+ const type = process.platform === "win32" ? "junction" : "dir";
181
+ await symlink(pkgRoot, link, type);
182
+ usedSymlink = true;
183
+ } catch {
184
+ // Swallow — falls back to paths-based tsconfig below.
185
+ }
186
+
187
+ // 2. Symlink @types/bun so `Bun.*` APIs have types in workflows
188
+ try {
189
+ const bunTypes = join(pkgRoot, "node_modules", "@types", "bun");
190
+ if (await pathExists(bunTypes)) {
191
+ const typesDir = join(destRoot, "node_modules", "@types");
192
+ await ensureDir(typesDir);
193
+
194
+ const link = join(typesDir, "bun");
195
+ await removeLinkOrDir(link);
196
+
197
+ const type = process.platform === "win32" ? "junction" : "dir";
198
+ await symlink(bunTypes, link, type);
199
+ }
200
+ } catch {
201
+ // Best effort — Bun APIs in workflows lack types but runtime is fine.
202
+ }
203
+
204
+ // 3. Generate a clean tsconfig for the destination
205
+ const compilerOptions: Record<string, unknown> = {
206
+ target: "ESNext",
207
+ module: "ESNext",
208
+ moduleResolution: "bundler",
209
+ allowImportingTsExtensions: true,
210
+ noEmit: true,
211
+ verbatimModuleSyntax: true,
212
+ strict: true,
213
+ skipLibCheck: true,
214
+ types: ["bun"],
215
+ };
216
+
217
+ if (!usedSymlink) {
218
+ // Fallback: absolute paths so TypeScript can still resolve the SDK
219
+ // source from the installed package location.
220
+ compilerOptions.paths = {
221
+ "@bastani/atomic": [join(pkgRoot, "src", "sdk", "index.ts")],
222
+ "@bastani/atomic/workflows": [join(pkgRoot, "src", "sdk", "workflows.ts")],
223
+ };
224
+ }
225
+
226
+ const tsconfig = { compilerOptions, include: GLOBAL_TSCONFIG_INCLUDE };
227
+
228
+ await Bun.write(
229
+ join(destRoot, "tsconfig.json"),
230
+ JSON.stringify(tsconfig, null, 2) + "\n",
231
+ );
105
232
  }
233
+
234
+ /** Include globs shared by every generated global workflows tsconfig. */
235
+ const GLOBAL_TSCONFIG_INCLUDE = [
236
+ "**/claude/**/*.ts",
237
+ "**/copilot/**/*.ts",
238
+ "**/opencode/**/*.ts",
239
+ "**/helpers/**/*.ts",
240
+ ];