@10stars/config 16.2.0 → 17.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@10stars/config",
3
- "version": "16.2.0",
3
+ "version": "17.0.1",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "config": "./src/index.ts"
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import path from "node:path"
5
5
 
6
6
  import { resetColorIndex } from "./colors"
7
7
  import { checkIsMonorepo, dedupeSingletons, linkPackages } from "./link-packages"
8
+ import { dedupeRemote, linkRemote } from "./link-remote"
8
9
  import { setupOverrides } from "./manage-overrides"
9
10
  import { runConcurrently, type CommandConfig } from "./runner"
10
11
  import { setupSideEffects } from "./vanilla-extract/manage-side-effects"
@@ -61,15 +62,25 @@ const actions = {
61
62
  const config = await getConfig()
62
63
  if (config?.linkPackages) {
63
64
  const linkedPaths = await linkPackages(cwd, config.linkPackages)
64
- if (config.dedupeSingletons !== false) {
65
- const singletons = Array.isArray(config.dedupeSingletons)
66
- ? config.dedupeSingletons
67
- : undefined
68
- await dedupeSingletons(cwd, config.linkPackages, linkedPaths, singletons)
69
- }
65
+ await dedupeSingletons(cwd, config.linkPackages, linkedPaths, config.dedupeSingletons)
66
+ }
67
+ },
68
+ },
69
+ linkRemote: {
70
+ info: `CI: clone the transitive file: sibling graph before "bun install" (branch-first; --from <manifest> to pin/revert, --emit-manifest <path> to record)`,
71
+ action: () => {
72
+ const args = process.argv.slice(3)
73
+ const flag = (name: string) => {
74
+ const i = args.indexOf(name)
75
+ return i >= 0 ? args[i + 1] : undefined
70
76
  }
77
+ return linkRemote(cwd, { manifestOut: flag(`--emit-manifest`), fromManifest: flag(`--from`) })
71
78
  },
72
79
  },
80
+ dedupe: {
81
+ info: `CI: dedupe type singletons across linked siblings onto the consumer, after "bun install"`,
82
+ action: () => dedupeRemote(cwd),
83
+ },
73
84
  ts: {
74
85
  info: `Typecheck code (in watch mode)`,
75
86
  action: () => exec(`tsc --project ./tsconfig.json --noEmit --watch`),
@@ -32,9 +32,8 @@ export const linkPackages = async (cwd: string, packagesToLink: string[]) => {
32
32
  )
33
33
  if (!pkgJson) return
34
34
 
35
- const [orgName, pkgName] = pkgJson.name!.split(`/`)
36
-
37
- const pathSymlink = path.join(cwd, `node_modules`, orgName, pkgName)
35
+ // path.join normalizes the `/` in a scoped name (`@common/env`) and handles bare names (`frontend-toolkit`)
36
+ const pathSymlink = path.join(cwd, `node_modules`, pkgJson.name!)
38
37
  await symlink(pathPkg, pathSymlink)
39
38
  linkedPaths.push(pathPkg)
40
39
  }
@@ -82,13 +81,18 @@ export const dedupeSingletons = async (
82
81
  cwd: string,
83
82
  packagesToLink: string[],
84
83
  linkedPaths: string[],
85
- singletons: string[] = DEFAULT_SINGLETONS,
84
+ singletons: boolean | string[] = true,
85
+ canonicalRoot?: string,
86
86
  ) => {
87
- // siblings share cwd's parent; the formula reduces to path.dirname(cwd) for `../pkg` entries
87
+ if (singletons === false) return
88
+ const packages = Array.isArray(singletons) ? singletons : DEFAULT_SINGLETONS
89
+
90
+ // siblings share cwd's parent; the formula reduces to path.dirname(cwd) for `../pkg` entries.
91
+ // canonicalRoot overrides this for standalone CI checkouts, where the consumer (cwd) holds the canonical copy.
88
92
  const roots = new Set(packagesToLink.map((rel) => path.resolve(cwd, rel, `..`)))
89
- const workspaceRoot = roots.size === 1 ? [...roots][0]! : path.dirname(cwd)
93
+ const workspaceRoot = canonicalRoot ?? (roots.size === 1 ? [...roots][0]! : path.dirname(cwd))
90
94
 
91
- for (const pkg of singletons) {
95
+ for (const pkg of packages) {
92
96
  const canonical = path.join(workspaceRoot, `node_modules`, pkg)
93
97
  const canonicalVersion = await readPkgVersion(canonical)
94
98
  if (!canonicalVersion) {
@@ -0,0 +1,312 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import fs from "node:fs/promises"
3
+ import path from "node:path"
4
+
5
+ import type * as TF from "type-fest"
6
+
7
+ import { dedupeSingletons } from "./link-packages"
8
+
9
+ interface Config {
10
+ dedupeSingletons?: boolean | string[]
11
+ }
12
+
13
+ interface ManifestSibling {
14
+ repo: string
15
+ path: string // relative to the consumer, e.g. "../common"
16
+ ref: string | null
17
+ sha: string | null
18
+ version: string | null // nearest git tag, if any
19
+ }
20
+
21
+ interface Manifest {
22
+ schema: 1
23
+ consumer: { repo: string; ref: string | null; sha: string | null }
24
+ createdAt: string
25
+ siblings: ManifestSibling[]
26
+ }
27
+
28
+ interface LinkRemoteOptions {
29
+ /** Write the resolved transitive closure ({repo, ref, sha, version}) to this path. */
30
+ manifestOut?: string
31
+ /** Pin-clone each sibling at the SHA recorded in this manifest (revert), instead of branch-first. */
32
+ fromManifest?: string
33
+ }
34
+
35
+ /** A discovered sibling repo root and the ref it resolved to (branch name, or null if detached/pinned). */
36
+ type Sibling = { abs: string; ref: string | null }
37
+
38
+ // execFile (not a shell) so branch names / refs / SHAs can't inject shell syntax.
39
+ const git = (args: string[], cwd?: string): string =>
40
+ execFileSync(`git`, args, { cwd, encoding: `utf8` }).trim()
41
+
42
+ const gitInherit = (args: string[], cwd?: string): void => {
43
+ execFileSync(`git`, args, { cwd, stdio: `inherit` })
44
+ }
45
+
46
+ const tryGit = (args: string[], cwd?: string): string | null => {
47
+ try {
48
+ return git(args, cwd)
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ const bunInstall = (cwd: string): void =>
55
+ void execFileSync(`bun`, [`install`], { cwd, stdio: `inherit` })
56
+
57
+ const exists = (p: string): Promise<boolean> =>
58
+ fs
59
+ .stat(p)
60
+ .then(() => true)
61
+ .catch(() => false)
62
+
63
+ const readPkg = async (dir: string): Promise<TF.PackageJson | null> => {
64
+ try {
65
+ return JSON.parse(await fs.readFile(path.join(dir, `package.json`), `utf8`))
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ const getConfig = async (cwd: string): Promise<Config | null> =>
72
+ import(path.join(cwd, `10stars.config`)).catch(() => null)
73
+
74
+ const getOrg = (): string => {
75
+ const org = process.env.GITHUB_ORG ?? process.env.GITHUB_REPOSITORY?.split(`/`)[0]
76
+ if (!org) {
77
+ throw new Error(
78
+ `Cannot determine org: set GITHUB_ORG, or run inside GitHub Actions (GITHUB_REPOSITORY)`,
79
+ )
80
+ }
81
+ return org
82
+ }
83
+
84
+ const repoUrl = (repo: string) => `https://github.com/${getOrg()}/${repo}.git`
85
+
86
+ /**
87
+ * Absolute repo roots referenced by one dir's `file:` deps.
88
+ * `file:../common/env` → `<dir>/../common`; whole-repo `file:../frontend-toolkit` → itself.
89
+ * Self-refs (`file:.`) and non-parent paths are skipped, so registry deps (incl.
90
+ * `@anime.club/translations`) are never included.
91
+ */
92
+ const fileSiblingRoots = async (dir: string): Promise<string[]> => {
93
+ const pkg = await readPkg(dir)
94
+ if (!pkg) return []
95
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
96
+ const roots = new Set<string>()
97
+ for (const spec of Object.values(allDeps)) {
98
+ if (typeof spec !== `string` || !spec.startsWith(`file:`)) continue
99
+ const rel = spec.slice(`file:`.length)
100
+ if (!rel.startsWith(`..`)) continue
101
+ const segments = rel.split(`/`)
102
+ const firstNamed = segments.findIndex((s) => s !== `..`)
103
+ if (firstNamed < 0) continue
104
+ roots.add(path.resolve(dir, segments.slice(0, firstNamed + 1).join(`/`)))
105
+ }
106
+ return [...roots]
107
+ }
108
+
109
+ /**
110
+ * BFS the transitive `file:` sibling graph from `cwd`, invoking `visit` once per newly discovered
111
+ * sibling root in dependents-first order. Cloning inside `visit` makes that sibling's own `file:`
112
+ * deps discoverable when it is later dequeued; a still-missing dir simply yields no children.
113
+ */
114
+ const bfsSiblings = async (cwd: string, visit: (sib: string) => Promise<void>): Promise<void> => {
115
+ const visited = new Set<string>([path.resolve(cwd)])
116
+ const queue = [path.resolve(cwd)]
117
+ while (queue.length) {
118
+ const dir = queue.shift()!
119
+ for (const sib of await fileSiblingRoots(dir)) {
120
+ if (visited.has(sib)) continue
121
+ visited.add(sib)
122
+ await visit(sib)
123
+ queue.push(sib)
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Branch-first: current PR branch, then push branch, then `develop`, then `production`. A single
130
+ * `ls-remote --heads` lists every head in one network round-trip; candidates are matched locally
131
+ * (avoids one request per ref). A non-zero exit means the repo is unreadable (private + missing/
132
+ * unauthorized clone token → "Repository not found") — surfaced immediately rather than masquerading
133
+ * as "No branch found".
134
+ */
135
+ const resolveRef = (url: string, repo: string): string => {
136
+ const candidates = [
137
+ process.env.GITHUB_HEAD_REF, // pull_request events
138
+ process.env.GITHUB_REF_NAME, // push events
139
+ `develop`,
140
+ `production`,
141
+ ].filter((v): v is string => Boolean(v))
142
+
143
+ let heads: string
144
+ try {
145
+ heads = git([`ls-remote`, `--heads`, url])
146
+ } catch (err) {
147
+ const stderr = (err as { stderr?: string | Buffer })?.stderr
148
+ throw new Error(
149
+ `Cannot read ${url}: repo is unreachable — if private, the clone token is missing or ` +
150
+ `unauthorized. Pass app-key to setup-siblings and install the GitHub App on this repo ` +
151
+ `with contents:read.\n${(stderr ?? (err as Error)?.message ?? ``).toString().trim()}`,
152
+ )
153
+ }
154
+
155
+ // Each line is "<sha>\trefs/heads/<branch>"; a branch may itself contain slashes (e.g. alice/x).
156
+ const present = new Set(
157
+ heads
158
+ .split(`\n`)
159
+ .map((line) => line.split(`refs/heads/`)[1])
160
+ .filter(Boolean),
161
+ )
162
+ for (const ref of candidates) if (present.has(ref)) return ref
163
+ throw new Error(
164
+ `No branch found for ${repo}; tried: ${candidates.join(`, `)} (repo reachable, refs absent)`,
165
+ )
166
+ }
167
+
168
+ const headSha = (dir: string): string | null => tryGit([`rev-parse`, `HEAD`], dir)
169
+
170
+ const nearestTag = (dir: string): string | null => tryGit([`describe`, `--tags`, `--abbrev=0`], dir)
171
+
172
+ const currentRef = (dir: string): string | null => {
173
+ const branch = tryGit([`rev-parse`, `--abbrev-ref`, `HEAD`], dir)
174
+ return branch === `HEAD` ? null : branch
175
+ }
176
+
177
+ const clonePinned = (url: string, sha: string, dir: string): void => {
178
+ gitInherit([`init`, dir])
179
+ gitInherit([`remote`, `add`, `origin`, url], dir)
180
+ gitInherit([`fetch`, `--depth`, `1`, `origin`, sha], dir) // GitHub allows fetching a reachable SHA
181
+ gitInherit([`checkout`, `FETCH_HEAD`], dir)
182
+ }
183
+
184
+ /**
185
+ * Walk the transitive `file:` graph from `cwd`, cloning each missing sibling as a flat peer.
186
+ * Returns siblings in discovery (BFS) order — dependents before their dependencies.
187
+ */
188
+ const cloneClosureBranchFirst = async (cwd: string): Promise<Sibling[]> => {
189
+ const order: Sibling[] = []
190
+ await bfsSiblings(cwd, async (sib) => {
191
+ let ref: string | null
192
+ if (await exists(sib)) {
193
+ console.info(`linkRemote: ${path.relative(cwd, sib)} already present; not cloning`)
194
+ ref = currentRef(sib)
195
+ } else {
196
+ const repo = path.basename(sib)
197
+ const url = repoUrl(repo)
198
+ ref = resolveRef(url, repo)
199
+ console.info(`linkRemote: cloning ${repo}@${ref} -> ${path.relative(cwd, sib)}`)
200
+ gitInherit([`clone`, `--depth`, `1`, `--single-branch`, `--branch`, ref, url, sib])
201
+ }
202
+ order.push({ abs: sib, ref })
203
+ })
204
+ return order
205
+ }
206
+
207
+ const writeManifest = async (cwd: string, siblings: Sibling[], out: string): Promise<void> => {
208
+ const manifest: Manifest = {
209
+ schema: 1,
210
+ consumer: {
211
+ repo: path.basename(path.resolve(cwd)),
212
+ ref: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? currentRef(cwd),
213
+ sha: headSha(cwd),
214
+ },
215
+ createdAt: new Date().toISOString(),
216
+ siblings: siblings.map(({ abs, ref }) => ({
217
+ repo: path.basename(abs),
218
+ path: path.relative(cwd, abs),
219
+ ref,
220
+ sha: headSha(abs),
221
+ version: nearestTag(abs),
222
+ })),
223
+ }
224
+ await fs.writeFile(out, JSON.stringify(manifest, null, 2) + `\n`)
225
+ console.info(`linkRemote: wrote manifest -> ${out}`)
226
+ }
227
+
228
+ const linkRemoteFromManifest = async (cwd: string, manifestPath: string): Promise<void> => {
229
+ const manifest: Manifest = JSON.parse(await fs.readFile(manifestPath, `utf8`))
230
+ if (!manifest.siblings?.length) {
231
+ console.info(`linkRemote --from: manifest has no siblings; nothing to do`)
232
+ return
233
+ }
234
+
235
+ for (const sib of manifest.siblings) {
236
+ const abs = path.resolve(cwd, sib.path)
237
+ if (await exists(abs)) {
238
+ console.info(`linkRemote --from: ${sib.path} already present; leaving as-is`)
239
+ continue
240
+ }
241
+ if (!sib.sha) throw new Error(`Manifest entry "${sib.repo}" has no sha to pin`)
242
+ console.info(`linkRemote --from: pinning ${sib.repo}@${sib.sha} -> ${sib.path}`)
243
+ clonePinned(repoUrl(sib.repo), sib.sha, abs)
244
+ }
245
+
246
+ installLeavesFirst(
247
+ cwd,
248
+ manifest.siblings.map((s) => path.resolve(cwd, s.path)),
249
+ )
250
+ }
251
+
252
+ /**
253
+ * Install siblings leaves-first (reverse of dependents-first discovery order) so a hoisted
254
+ * sibling (e.g. `common`) is installed before a repo that links into it.
255
+ * Assumes an acyclic, mostly-linear `file:` graph; a diamond could need a real topological sort.
256
+ */
257
+ const installLeavesFirst = (cwd: string, siblingsInDiscoveryOrder: string[]): void => {
258
+ for (const abs of [...siblingsInDiscoveryOrder].reverse()) {
259
+ console.info(`linkRemote: bun install in ${path.relative(cwd, abs)}`)
260
+ bunInstall(abs)
261
+ }
262
+ }
263
+
264
+ /**
265
+ * CI: materialize the transitive `file:` sibling graph that a standalone checkout lacks.
266
+ *
267
+ * Default (branch-first): BFS the graph, shallow single-branch clone each missing sibling at
268
+ * its resolved ref, then `bun install` leaves-first. `--emit-manifest` records the exact
269
+ * {repo, ref, sha, version} set (a cross-repo lockfile snapshot for revert). `--from` skips
270
+ * branch-first and pin-clones each sibling at the manifest's recorded SHA.
271
+ *
272
+ * Linking is done by the consumer's own `bun install` resolving the `file:` specs afterwards;
273
+ * run `dedupe` after that. No-op for repos with no `file:` siblings, and skips siblings already
274
+ * present (local dev).
275
+ */
276
+ export const linkRemote = async (cwd: string, opts: LinkRemoteOptions = {}): Promise<void> => {
277
+ if (opts.fromManifest) return linkRemoteFromManifest(cwd, opts.fromManifest)
278
+
279
+ const siblings = await cloneClosureBranchFirst(cwd)
280
+ if (!siblings.length) {
281
+ console.info(`linkRemote: no file: siblings; nothing to do`)
282
+ return
283
+ }
284
+
285
+ installLeavesFirst(
286
+ cwd,
287
+ siblings.map((s) => s.abs),
288
+ )
289
+
290
+ if (opts.manifestOut) await writeManifest(cwd, siblings, opts.manifestOut)
291
+ }
292
+
293
+ /** Present transitive `file:` siblings (no cloning) — used for post-install dedupe. */
294
+ const collectPresentSiblings = async (cwd: string): Promise<string[]> => {
295
+ const found: string[] = []
296
+ await bfsSiblings(cwd, async (sib) => {
297
+ if (await exists(sib)) found.push(sib)
298
+ })
299
+ return found
300
+ }
301
+
302
+ /**
303
+ * CI, run AFTER the consumer's `bun install`: collapse type singletons (`@types/react`, …)
304
+ * across the transitive linked graph onto the consumer's own copy. A standalone CI checkout
305
+ * has no hoisted workspace root, so the canonical is the consumer (`cwd`).
306
+ */
307
+ export const dedupeRemote = async (cwd: string): Promise<void> => {
308
+ const config = await getConfig(cwd)
309
+ const siblings = await collectPresentSiblings(cwd)
310
+ if (!siblings.length) return
311
+ await dedupeSingletons(cwd, [], siblings, config?.dedupeSingletons, cwd)
312
+ }