@10stars/config 16.2.0 → 17.0.0
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 +1 -1
- package/src/index.ts +17 -6
- package/src/link-packages.ts +11 -7
- package/src/link-remote.ts +288 -0
package/package.json
CHANGED
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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`),
|
package/src/link-packages.ts
CHANGED
|
@@ -32,9 +32,8 @@ export const linkPackages = async (cwd: string, packagesToLink: string[]) => {
|
|
|
32
32
|
)
|
|
33
33
|
if (!pkgJson) return
|
|
34
34
|
|
|
35
|
-
|
|
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[] =
|
|
84
|
+
singletons: boolean | string[] = true,
|
|
85
|
+
canonicalRoot?: string,
|
|
86
86
|
) => {
|
|
87
|
-
|
|
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
|
|
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,288 @@
|
|
|
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
|
+
const branchExists = (url: string, ref: string): boolean =>
|
|
129
|
+
Boolean(tryGit([`ls-remote`, `--heads`, url, ref]))
|
|
130
|
+
|
|
131
|
+
/** Branch-first: current PR branch, then push branch, then `develop`, then `production`. */
|
|
132
|
+
const resolveRef = (url: string, repo: string): string => {
|
|
133
|
+
const candidates = [
|
|
134
|
+
process.env.GITHUB_HEAD_REF, // pull_request events
|
|
135
|
+
process.env.GITHUB_REF_NAME, // push events
|
|
136
|
+
`develop`,
|
|
137
|
+
`production`,
|
|
138
|
+
].filter((v): v is string => Boolean(v))
|
|
139
|
+
|
|
140
|
+
for (const ref of candidates) if (branchExists(url, ref)) return ref
|
|
141
|
+
throw new Error(`No branch found for ${repo}; tried: ${candidates.join(`, `)}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const headSha = (dir: string): string | null => tryGit([`rev-parse`, `HEAD`], dir)
|
|
145
|
+
|
|
146
|
+
const nearestTag = (dir: string): string | null => tryGit([`describe`, `--tags`, `--abbrev=0`], dir)
|
|
147
|
+
|
|
148
|
+
const currentRef = (dir: string): string | null => {
|
|
149
|
+
const branch = tryGit([`rev-parse`, `--abbrev-ref`, `HEAD`], dir)
|
|
150
|
+
return branch === `HEAD` ? null : branch
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const clonePinned = (url: string, sha: string, dir: string): void => {
|
|
154
|
+
gitInherit([`init`, dir])
|
|
155
|
+
gitInherit([`remote`, `add`, `origin`, url], dir)
|
|
156
|
+
gitInherit([`fetch`, `--depth`, `1`, `origin`, sha], dir) // GitHub allows fetching a reachable SHA
|
|
157
|
+
gitInherit([`checkout`, `FETCH_HEAD`], dir)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Walk the transitive `file:` graph from `cwd`, cloning each missing sibling as a flat peer.
|
|
162
|
+
* Returns siblings in discovery (BFS) order — dependents before their dependencies.
|
|
163
|
+
*/
|
|
164
|
+
const cloneClosureBranchFirst = async (cwd: string): Promise<Sibling[]> => {
|
|
165
|
+
const order: Sibling[] = []
|
|
166
|
+
await bfsSiblings(cwd, async (sib) => {
|
|
167
|
+
let ref: string | null
|
|
168
|
+
if (await exists(sib)) {
|
|
169
|
+
console.info(`linkRemote: ${path.relative(cwd, sib)} already present; not cloning`)
|
|
170
|
+
ref = currentRef(sib)
|
|
171
|
+
} else {
|
|
172
|
+
const repo = path.basename(sib)
|
|
173
|
+
const url = repoUrl(repo)
|
|
174
|
+
ref = resolveRef(url, repo)
|
|
175
|
+
console.info(`linkRemote: cloning ${repo}@${ref} -> ${path.relative(cwd, sib)}`)
|
|
176
|
+
gitInherit([`clone`, `--depth`, `1`, `--single-branch`, `--branch`, ref, url, sib])
|
|
177
|
+
}
|
|
178
|
+
order.push({ abs: sib, ref })
|
|
179
|
+
})
|
|
180
|
+
return order
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const writeManifest = async (cwd: string, siblings: Sibling[], out: string): Promise<void> => {
|
|
184
|
+
const manifest: Manifest = {
|
|
185
|
+
schema: 1,
|
|
186
|
+
consumer: {
|
|
187
|
+
repo: path.basename(path.resolve(cwd)),
|
|
188
|
+
ref: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? currentRef(cwd),
|
|
189
|
+
sha: headSha(cwd),
|
|
190
|
+
},
|
|
191
|
+
createdAt: new Date().toISOString(),
|
|
192
|
+
siblings: siblings.map(({ abs, ref }) => ({
|
|
193
|
+
repo: path.basename(abs),
|
|
194
|
+
path: path.relative(cwd, abs),
|
|
195
|
+
ref,
|
|
196
|
+
sha: headSha(abs),
|
|
197
|
+
version: nearestTag(abs),
|
|
198
|
+
})),
|
|
199
|
+
}
|
|
200
|
+
await fs.writeFile(out, JSON.stringify(manifest, null, 2) + `\n`)
|
|
201
|
+
console.info(`linkRemote: wrote manifest -> ${out}`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const linkRemoteFromManifest = async (cwd: string, manifestPath: string): Promise<void> => {
|
|
205
|
+
const manifest: Manifest = JSON.parse(await fs.readFile(manifestPath, `utf8`))
|
|
206
|
+
if (!manifest.siblings?.length) {
|
|
207
|
+
console.info(`linkRemote --from: manifest has no siblings; nothing to do`)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const sib of manifest.siblings) {
|
|
212
|
+
const abs = path.resolve(cwd, sib.path)
|
|
213
|
+
if (await exists(abs)) {
|
|
214
|
+
console.info(`linkRemote --from: ${sib.path} already present; leaving as-is`)
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
if (!sib.sha) throw new Error(`Manifest entry "${sib.repo}" has no sha to pin`)
|
|
218
|
+
console.info(`linkRemote --from: pinning ${sib.repo}@${sib.sha} -> ${sib.path}`)
|
|
219
|
+
clonePinned(repoUrl(sib.repo), sib.sha, abs)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
installLeavesFirst(
|
|
223
|
+
cwd,
|
|
224
|
+
manifest.siblings.map((s) => path.resolve(cwd, s.path)),
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Install siblings leaves-first (reverse of dependents-first discovery order) so a hoisted
|
|
230
|
+
* sibling (e.g. `common`) is installed before a repo that links into it.
|
|
231
|
+
* Assumes an acyclic, mostly-linear `file:` graph; a diamond could need a real topological sort.
|
|
232
|
+
*/
|
|
233
|
+
const installLeavesFirst = (cwd: string, siblingsInDiscoveryOrder: string[]): void => {
|
|
234
|
+
for (const abs of [...siblingsInDiscoveryOrder].reverse()) {
|
|
235
|
+
console.info(`linkRemote: bun install in ${path.relative(cwd, abs)}`)
|
|
236
|
+
bunInstall(abs)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* CI: materialize the transitive `file:` sibling graph that a standalone checkout lacks.
|
|
242
|
+
*
|
|
243
|
+
* Default (branch-first): BFS the graph, shallow single-branch clone each missing sibling at
|
|
244
|
+
* its resolved ref, then `bun install` leaves-first. `--emit-manifest` records the exact
|
|
245
|
+
* {repo, ref, sha, version} set (a cross-repo lockfile snapshot for revert). `--from` skips
|
|
246
|
+
* branch-first and pin-clones each sibling at the manifest's recorded SHA.
|
|
247
|
+
*
|
|
248
|
+
* Linking is done by the consumer's own `bun install` resolving the `file:` specs afterwards;
|
|
249
|
+
* run `dedupe` after that. No-op for repos with no `file:` siblings, and skips siblings already
|
|
250
|
+
* present (local dev).
|
|
251
|
+
*/
|
|
252
|
+
export const linkRemote = async (cwd: string, opts: LinkRemoteOptions = {}): Promise<void> => {
|
|
253
|
+
if (opts.fromManifest) return linkRemoteFromManifest(cwd, opts.fromManifest)
|
|
254
|
+
|
|
255
|
+
const siblings = await cloneClosureBranchFirst(cwd)
|
|
256
|
+
if (!siblings.length) {
|
|
257
|
+
console.info(`linkRemote: no file: siblings; nothing to do`)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
installLeavesFirst(
|
|
262
|
+
cwd,
|
|
263
|
+
siblings.map((s) => s.abs),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if (opts.manifestOut) await writeManifest(cwd, siblings, opts.manifestOut)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Present transitive `file:` siblings (no cloning) — used for post-install dedupe. */
|
|
270
|
+
const collectPresentSiblings = async (cwd: string): Promise<string[]> => {
|
|
271
|
+
const found: string[] = []
|
|
272
|
+
await bfsSiblings(cwd, async (sib) => {
|
|
273
|
+
if (await exists(sib)) found.push(sib)
|
|
274
|
+
})
|
|
275
|
+
return found
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* CI, run AFTER the consumer's `bun install`: collapse type singletons (`@types/react`, …)
|
|
280
|
+
* across the transitive linked graph onto the consumer's own copy. A standalone CI checkout
|
|
281
|
+
* has no hoisted workspace root, so the canonical is the consumer (`cwd`).
|
|
282
|
+
*/
|
|
283
|
+
export const dedupeRemote = async (cwd: string): Promise<void> => {
|
|
284
|
+
const config = await getConfig(cwd)
|
|
285
|
+
const siblings = await collectPresentSiblings(cwd)
|
|
286
|
+
if (!siblings.length) return
|
|
287
|
+
await dedupeSingletons(cwd, [], siblings, config?.dedupeSingletons, cwd)
|
|
288
|
+
}
|