@_linked/cli 1.46.2 → 1.47.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.
Files changed (167) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +44 -36
  3. package/hooks/drift.cjs +169 -0
  4. package/hooks/git-hook.cjs +170 -0
  5. package/hooks/hook-shim.cjs +63 -0
  6. package/hooks/lock-at-rest.cjs +268 -0
  7. package/hooks/manifests.cjs +216 -0
  8. package/hooks/platform.cjs +25 -0
  9. package/lib/esm/app-release/build-backend.d.ts.map +1 -1
  10. package/lib/esm/app-release/build-backend.js +2 -1
  11. package/lib/esm/app-release/build-backend.js.map +1 -1
  12. package/lib/esm/cli-methods.d.ts.map +1 -1
  13. package/lib/esm/cli-methods.js +166 -150
  14. package/lib/esm/cli-methods.js.map +1 -1
  15. package/lib/esm/cli.js +20 -22
  16. package/lib/esm/cli.js.map +1 -1
  17. package/lib/esm/commands/build-app.d.ts.map +1 -1
  18. package/lib/esm/commands/build-app.js +2 -1
  19. package/lib/esm/commands/build-app.js.map +1 -1
  20. package/lib/esm/commands/build-package.d.ts.map +1 -1
  21. package/lib/esm/commands/build-package.js +6 -2
  22. package/lib/esm/commands/build-package.js.map +1 -1
  23. package/lib/esm/commands/localize.d.ts +9 -15
  24. package/lib/esm/commands/localize.d.ts.map +1 -1
  25. package/lib/esm/commands/localize.js +43 -74
  26. package/lib/esm/commands/localize.js.map +1 -1
  27. package/lib/esm/commands/setup-publish.d.ts.map +1 -1
  28. package/lib/esm/commands/setup-publish.js +22 -15
  29. package/lib/esm/commands/setup-publish.js.map +1 -1
  30. package/lib/esm/commands/start.d.ts +8 -0
  31. package/lib/esm/commands/start.d.ts.map +1 -1
  32. package/lib/esm/commands/start.js +20 -8
  33. package/lib/esm/commands/start.js.map +1 -1
  34. package/lib/esm/installed-packages.d.ts +5 -13
  35. package/lib/esm/installed-packages.d.ts.map +1 -1
  36. package/lib/esm/installed-packages.js +5 -38
  37. package/lib/esm/installed-packages.js.map +1 -1
  38. package/lib/esm/lifecycle.d.ts +35 -22
  39. package/lib/esm/lifecycle.d.ts.map +1 -1
  40. package/lib/esm/lifecycle.js +62 -36
  41. package/lib/esm/lifecycle.js.map +1 -1
  42. package/lib/esm/localize/adopt.d.ts +5 -5
  43. package/lib/esm/localize/adopt.d.ts.map +1 -1
  44. package/lib/esm/localize/adopt.js +46 -55
  45. package/lib/esm/localize/adopt.js.map +1 -1
  46. package/lib/esm/localize/app-root.d.ts +16 -0
  47. package/lib/esm/localize/app-root.d.ts.map +1 -0
  48. package/lib/esm/localize/app-root.js +94 -0
  49. package/lib/esm/localize/app-root.js.map +1 -0
  50. package/lib/esm/localize/app-setup.d.ts +21 -0
  51. package/lib/esm/localize/app-setup.d.ts.map +1 -0
  52. package/lib/esm/localize/app-setup.js +178 -0
  53. package/lib/esm/localize/app-setup.js.map +1 -0
  54. package/lib/esm/localize/delocalize.d.ts.map +1 -1
  55. package/lib/esm/localize/delocalize.js +168 -104
  56. package/lib/esm/localize/delocalize.js.map +1 -1
  57. package/lib/esm/localize/drift-hook.d.ts +18 -0
  58. package/lib/esm/localize/drift-hook.d.ts.map +1 -0
  59. package/lib/esm/localize/drift-hook.js +37 -0
  60. package/lib/esm/localize/drift-hook.js.map +1 -0
  61. package/lib/esm/localize/drift.d.ts +36 -0
  62. package/lib/esm/localize/drift.d.ts.map +1 -0
  63. package/lib/esm/localize/drift.js +34 -0
  64. package/lib/esm/localize/drift.js.map +1 -0
  65. package/lib/esm/localize/errors.d.ts +14 -8
  66. package/lib/esm/localize/errors.d.ts.map +1 -1
  67. package/lib/esm/localize/errors.js +14 -8
  68. package/lib/esm/localize/errors.js.map +1 -1
  69. package/lib/esm/localize/fsops.d.ts +11 -27
  70. package/lib/esm/localize/fsops.d.ts.map +1 -1
  71. package/lib/esm/localize/fsops.js +40 -115
  72. package/lib/esm/localize/fsops.js.map +1 -1
  73. package/lib/esm/localize/git-hooks.d.ts +33 -0
  74. package/lib/esm/localize/git-hooks.d.ts.map +1 -0
  75. package/lib/esm/localize/git-hooks.js +146 -0
  76. package/lib/esm/localize/git-hooks.js.map +1 -0
  77. package/lib/esm/localize/hook-module.d.ts +6 -0
  78. package/lib/esm/localize/hook-module.d.ts.map +1 -0
  79. package/lib/esm/localize/hook-module.js +37 -0
  80. package/lib/esm/localize/hook-module.js.map +1 -0
  81. package/lib/esm/localize/index.d.ts +14 -15
  82. package/lib/esm/localize/index.d.ts.map +1 -1
  83. package/lib/esm/localize/index.js +8 -9
  84. package/lib/esm/localize/index.js.map +1 -1
  85. package/lib/esm/localize/list.d.ts +6 -10
  86. package/lib/esm/localize/list.d.ts.map +1 -1
  87. package/lib/esm/localize/list.js +48 -189
  88. package/lib/esm/localize/list.js.map +1 -1
  89. package/lib/esm/localize/localize.d.ts +41 -62
  90. package/lib/esm/localize/localize.d.ts.map +1 -1
  91. package/lib/esm/localize/localize.js +398 -285
  92. package/lib/esm/localize/localize.js.map +1 -1
  93. package/lib/esm/localize/members.d.ts +13 -0
  94. package/lib/esm/localize/members.d.ts.map +1 -0
  95. package/lib/esm/localize/members.js +46 -0
  96. package/lib/esm/localize/members.js.map +1 -0
  97. package/lib/esm/localize/resolve.d.ts +1 -2
  98. package/lib/esm/localize/resolve.d.ts.map +1 -1
  99. package/lib/esm/localize/resolve.js +3 -5
  100. package/lib/esm/localize/resolve.js.map +1 -1
  101. package/lib/esm/localize/run.d.ts +1 -0
  102. package/lib/esm/localize/run.d.ts.map +1 -1
  103. package/lib/esm/localize/run.js +11 -6
  104. package/lib/esm/localize/run.js.map +1 -1
  105. package/lib/esm/localize/setup-lockfile.d.ts +13 -0
  106. package/lib/esm/localize/setup-lockfile.d.ts.map +1 -0
  107. package/lib/esm/localize/setup-lockfile.js +80 -0
  108. package/lib/esm/localize/setup-lockfile.js.map +1 -0
  109. package/lib/esm/utils/createPackageLocation.d.ts +4 -13
  110. package/lib/esm/utils/createPackageLocation.d.ts.map +1 -1
  111. package/lib/esm/utils/createPackageLocation.js +12 -21
  112. package/lib/esm/utils/createPackageLocation.js.map +1 -1
  113. package/lib/esm/utils/packageSetup.d.ts +5 -2
  114. package/lib/esm/utils/packageSetup.d.ts.map +1 -1
  115. package/lib/esm/utils/packageSetup.js +9 -3
  116. package/lib/esm/utils/packageSetup.js.map +1 -1
  117. package/lib/esm/utils/platform.d.ts +24 -0
  118. package/lib/esm/utils/platform.d.ts.map +1 -0
  119. package/lib/esm/utils/platform.js +62 -0
  120. package/lib/esm/utils/platform.js.map +1 -0
  121. package/lib/esm/utils/shapeReferences.d.ts.map +1 -1
  122. package/lib/esm/utils/shapeReferences.js +3 -2
  123. package/lib/esm/utils/shapeReferences.js.map +1 -1
  124. package/lib/esm/utils/spawn.d.ts +65 -0
  125. package/lib/esm/utils/spawn.d.ts.map +1 -0
  126. package/lib/esm/utils/spawn.js +181 -0
  127. package/lib/esm/utils/spawn.js.map +1 -0
  128. package/lib/esm/utils.d.ts.map +1 -1
  129. package/lib/esm/utils.js +19 -5
  130. package/lib/esm/utils.js.map +1 -1
  131. package/lib/esm/vite-config.d.ts +5 -12
  132. package/lib/esm/vite-config.d.ts.map +1 -1
  133. package/lib/esm/vite-config.js +41 -33
  134. package/lib/esm/vite-config.js.map +1 -1
  135. package/package.json +4 -3
  136. package/lib/esm/localize/ensure.d.ts +0 -44
  137. package/lib/esm/localize/ensure.d.ts.map +0 -1
  138. package/lib/esm/localize/ensure.js +0 -151
  139. package/lib/esm/localize/ensure.js.map +0 -1
  140. package/lib/esm/localize/manifest.d.ts +0 -63
  141. package/lib/esm/localize/manifest.d.ts.map +0 -1
  142. package/lib/esm/localize/manifest.js +0 -133
  143. package/lib/esm/localize/manifest.js.map +0 -1
  144. package/lib/esm/localize/one-copy-hook.d.ts +0 -16
  145. package/lib/esm/localize/one-copy-hook.d.ts.map +0 -1
  146. package/lib/esm/localize/one-copy-hook.js +0 -20
  147. package/lib/esm/localize/one-copy-hook.js.map +0 -1
  148. package/lib/esm/localize/provided.d.ts +0 -28
  149. package/lib/esm/localize/provided.d.ts.map +0 -1
  150. package/lib/esm/localize/provided.js +0 -89
  151. package/lib/esm/localize/provided.js.map +0 -1
  152. package/lib/esm/localize/prune.d.ts +0 -182
  153. package/lib/esm/localize/prune.d.ts.map +0 -1
  154. package/lib/esm/localize/prune.js +0 -703
  155. package/lib/esm/localize/prune.js.map +0 -1
  156. package/lib/esm/localize/relink.d.ts +0 -13
  157. package/lib/esm/localize/relink.d.ts.map +0 -1
  158. package/lib/esm/localize/relink.js +0 -181
  159. package/lib/esm/localize/relink.js.map +0 -1
  160. package/lib/esm/localized-dedupe.d.ts +0 -26
  161. package/lib/esm/localized-dedupe.d.ts.map +0 -1
  162. package/lib/esm/localized-dedupe.js +0 -139
  163. package/lib/esm/localized-dedupe.js.map +0 -1
  164. package/lib/esm/utils/relinkHooks.d.ts +0 -19
  165. package/lib/esm/utils/relinkHooks.d.ts.map +0 -1
  166. package/lib/esm/utils/relinkHooks.js +0 -74
  167. package/lib/esm/utils/relinkHooks.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,118 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.47.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#268](https://github.com/linked-fw/cli/pull/268) [`f4db679`](https://github.com/linked-fw/cli/commit/f4db679591afc51ba5f4a8313d37580c18ee62d9) Thanks [@flyon](https://github.com/flyon)! - `linked localize`, `--list` and `delocalize` no longer warn about a leftover `local-packages.json`. The file was never read by the workspace-based localize; delete it if one is still around. `docs/localize.md` drops its one-time migration section.
8
+
9
+ ## 1.47.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#265](https://github.com/linked-fw/cli/pull/265) [`2185964`](https://github.com/linked-fw/cli/commit/2185964a8ddbe910812ff0b1bf6dbae76ec82e47) Thanks [@flyon](https://github.com/flyon)! - `linked localize` now builds on npm workspaces. A checkout in `packages-local/` is a workspace
14
+ member through the app's `"packages-local/*"` glob, so npm links it and dedupes everything below
15
+ it; nothing is installed inside a checkout, pruned or symlinked by hand any more. A lock-at-rest
16
+ hook keeps the committed `package-lock.json` clean while checkouts are linked, git hooks stop a
17
+ leaked lockfile from being committed, and a drift report says when the linked tree is not what
18
+ the app declares. The full guide is `docs/localize.md`.
19
+
20
+ **To adopt it**
21
+
22
+ 1. Update `@_linked/cli`.
23
+ 2. Run `npx linked localize <pkg>` once. Its first run sets the app up and prints what changed:
24
+ `"packages-local/*"` in `workspaces`, `postinstall` and `dependencies` scripts that run the
25
+ lock-at-rest hook, `packages-local/` and `.packages-local.parked/` in `.gitignore`, and the
26
+ matching `package-lock.json`. Commit exactly those three files (`package.json`,
27
+ `package-lock.json`, `.gitignore`); `git status` shows nothing else. An app that already has the
28
+ earlier unguarded hook command is upgraded in place.
29
+ 3. Remove `linked localize --ensure` from `pre*` scripts and `--relink` from `postinstall` (the
30
+ set-up replaces it).
31
+ 4. Migrating checkouts made by the old localize: delete `local-packages.json` and each checkout's
32
+ own `node_modules`, then take them all in with one `npx linked localize <names…> --adopt`. See
33
+ "Migrating from the old localize" in `docs/localize.md`.
34
+
35
+ The hook scripts run through a guard that does nothing where the cli is not installed, so
36
+ `npm ci --omit=dev` (Docker, production) is unaffected. `npm ci` itself refuses while checkouts are
37
+ present; use `npm install` then.
38
+
39
+ **Commands and flags**
40
+
41
+ - **Changed:** `linked delocalize [names…]` (no names: every checkout) now parks checkouts in
42
+ `.packages-local.parked/` and reinstalls the registry copies itself;
43
+ `linked localize <name>` or the new `linked localize --parked` takes them back. `--purge`
44
+ deletes instead, and refuses unless the work is saved elsewhere: uncommitted changes, commits no
45
+ remote has (on any branch or a detached `HEAD`), a stash, a checkout without `.git` or any
46
+ failing git call all refuse; `--force` overrides.
47
+ - **New:** `--subdir` clones a monorepo whole into `packages-local/.repos/` and links the package
48
+ inside it.
49
+ - **New:** `linked localize --list --check` prints only the drift and exits 1 when there is any,
50
+ for CI.
51
+ - **Removed:** `--reinstall`, `--no-prune` and `localize --force`, with pruning and the Vite dedupe
52
+ localize used to derive. `--dir` accepts only `packages-local` (exit 2 otherwise).
53
+ - **Deprecated:** `--relink` and `--ensure` do nothing and exit 0, so existing scripts keep
54
+ installing. Remove them.
55
+ - `local-packages.json` is no longer read; the record is the directories in `packages-local/`.
56
+ - Every command acts on the app root: the nearest directory whose `package.json` has `workspaces`,
57
+ climbing further only through ancestors whose globs match it. They work from inside a member, and
58
+ a git worktree nested in the app stays its own app.
59
+
60
+ **Exit codes.** 1: `--list --check` found drift. 2: bad command line. 6: any guard refusal,
61
+ including a refused `--purge` (was 5) and an `overrides` key naming a localized package (npm
62
+ refuses those with `EOVERRIDE`; localize names the key and changes nothing). 7: `npm install` at
63
+ the app root failed. 3 (unusable record file) and 8 (manifests changed during the run) are retired
64
+ and not reused.
65
+
66
+ **Git hooks.** localize writes `pre-commit` (refuses a staged `package.json` or
67
+ `package-lock.json` that lists a checkout, and refuses when git cannot show what is staged) and
68
+ `post-merge`/`post-checkout` (`npm install` when the dependency manifests changed; nothing in a
69
+ work tree without checkouts). They work for an app below the git top level and in linked
70
+ worktrees (which share them; localize says so). Under `core.hooksPath` (husky) nothing is written:
71
+ localize prints the one line to add to each hook, which refuses wherever in the file it sits. A
72
+ hook file you wrote is left alone. A clone that has checkouts but no hooks gets a one-line notice
73
+ from lock-at-rest naming the command that writes them.
74
+
75
+ **Lock-at-rest.** After every install it restores the committed (or deliberately staged)
76
+ `package-lock.json`. While `package.json` differs from `HEAD` it leaves the lockfile alone and
77
+ warns that it must not be committed: change dependencies with `linked delocalize` first.
78
+
79
+ **Drift.** Reported by `--list`, by lock-at-rest after an install that changed the tree, and
80
+ before `start`, `script`, `call` and `build-all`: a range a checkout misses, a second copy of a
81
+ localized package, a nested copy of `@_linked/core`, `@_linked/react` or `@_linked/server-utils`,
82
+ and a checkout npm installed that is gone. It only reports, except under `--list --check`.
83
+
84
+ **`build-all`** builds the cli's own dependencies (a localized `@_linked/core`) first, then the
85
+ cli alone, then the rest; a localized cli builds with its own `npm run build`.
86
+
87
+ **Windows (written for it, not yet run there).** `npm`, `npx` and `.bin` shims start through one
88
+ helper (`node npm-cli.js` when npm started the cli, else the `.cmd` through `cmd.exe` with every
89
+ argument quoted), in the commands and in the `post-merge`/`post-checkout` hook. `--subdir` links
90
+ are junctions; files are imported by `file:` URL; POSIX-only shell use (`cd <dir> &&` chains,
91
+ `/dev/tty`, single-quoted strings, `chmod` in the cli's own build) is gone; edited
92
+ `package.json`, `.gitignore`, `.npmrc` and source files keep CRLF. README "Windows" lists what is
93
+ left.
94
+
95
+ **Removed deep imports** (reachable before through the `@_linked/cli/*` subpath exports; no known
96
+ consumer):
97
+
98
+ - `vite-config`: `localizedDedupe`;
99
+ - `utils/createPackageLocation`: `ensureRelinkPostinstall`, `ensureRelinkHooks`, `RELINK_COMMAND`;
100
+ - `installed-packages`: `readInstalledPkgSync`;
101
+ - `lifecycle`: `BuildToolOrder.buildToolFirst` (the type now has `pre` and `start`);
102
+ - `commands/localize`: `pruneOptions`;
103
+ - `localize/index`: `relink`, `pruneProvided`, `shouldPrune`, `providedPackages`, `ensure`,
104
+ `reinstall`, `checkOneCopy`, `readManifest`, `writeManifest`, `manifestPath`,
105
+ `MANIFEST_FILENAME`, `SCHEMA_VERSION`, `DEFAULT_DIR`, `EXIT_BAD_FILE`, `EXIT_MANIFEST_DIRTY`, and
106
+ the types `Manifest`, `ManifestEntry`, `PruneOptions`, `PruneScope`, `Provided`, `ProvidedSkip`
107
+ (`satisfies` stays, now drift's range check);
108
+ - `localize/localize`: `forEachName`, `installLinkAndRecord`, `guardForeignLink`, `declaredRange`,
109
+ `assertManifestsUntouched`, and the types `OneName`, `OneResult`;
110
+ - `localize/fsops`: `linkTarget`, `writeLink`, `scanLinks`, `MANIFEST_FILES`,
111
+ `snapshotManifests`, `diffManifests`, `isGitCheckout`;
112
+ - `localize/errors`: `EXIT_BAD_FILE`, `EXIT_MANIFEST_DIRTY`;
113
+ - whole modules: `localized-dedupe`, `utils/relinkHooks`, `localize/prune`, `localize/ensure`,
114
+ `localize/relink`, `localize/provided`, `localize/manifest`, `localize/one-copy-hook`.
115
+
3
116
  ## 1.46.2
4
117
 
5
118
  ### Patch Changes
package/README.md CHANGED
@@ -13,7 +13,7 @@ npm install --save-dev @_linked/cli
13
13
  One executable ships in this package: `linked`. (`lnk`, `lincd` and `lincd-cli` were dropped in
14
14
  [#40](https://github.com/linked-fw/cli/pull/40).)
15
15
 
16
- In a project that has `@_linked/cli` installed — any linked app, including Create Now — run it as
16
+ In a project that has `@_linked/cli` installed — any linked app — run it as
17
17
  `npx linked <command>`. Everywhere else, name the package: `npx @_linked/cli <command>`.
18
18
  A bare `npx linked` outside such a project does **not** reach this CLI: npm resolves it to the
19
19
  unrelated `linked` package on the registry, which has no binary, and fails with
@@ -77,9 +77,11 @@ linked create-package @_linked/foo --remote <git-url> [--push] # the same, with
77
77
  - `packages/foo` — a workspace member: added to the app's `dependencies` (and `packages/*` to its
78
78
  `workspaces` if missing), installed at the app root.
79
79
  - `packages-local/_linked-foo` — the name `linked localize` uses. `git init` and a first commit,
80
- `origin` and `repository.url` from `--remote`, pushed with `--push`; then installed, built,
81
- linked and recorded by `linked localize --adopt`. It is not added to the app's `dependencies`
82
- until it is published. `--remote` or `--push` imply this location.
80
+ `origin` and `repository.url` from `--remote`, pushed with `--push`; then handed to
81
+ `linked localize --adopt`, which sets the app up for checkouts if needed (`packages-local/*` in
82
+ `workspaces`, the lock-at-rest hook, `.gitignore` and the matching lockfile; commit those), installs at the app root so npm
83
+ links it as a workspace member, and builds it. It is not added to the app's `dependencies` until
84
+ it is published. `--remote` or `--push` imply this location.
83
85
 
84
86
  Any of the three flags skips the question. With none of them and no terminal, it exits 1 and says
85
87
  which flag to pass. An existing target folder is refused. Outside an app nothing changes: the
@@ -261,42 +263,37 @@ linked app-doctor # in an app: check optimizeDeps.include and Re
261
263
 
262
264
  ### Developing a dependency from a git checkout
263
265
 
264
- `linked localize` clones an npm dependency, installs inside the checkout, builds it with
265
- `linked build` and symlinks it into `node_modules` — **without** touching `package.json` or
266
- `package-lock.json`, so the manifest still describes the released dependency and CI is unaffected.
267
- Full guide: [docs/localize.md](docs/localize.md).
266
+ `linked localize` clones an npm dependency into `packages-local/`, which the app lists in its
267
+ `workspaces` (`"packages-local/*"`), so npm links and dedupes the checkout like any workspace
268
+ member. A hook the app's `postinstall` and `dependencies` scripts run (`hooks/lock-at-rest.cjs`
269
+ in this package, called through a guard that does nothing where the cli is not installed) puts the
270
+ committed `package-lock.json` back after every install, so the working tree stays clean and CI, which has no checkouts,
271
+ installs from the registry as always. Full guide: [docs/localize.md](docs/localize.md).
268
272
 
269
273
  ```bash
270
- linked localize @_linked/rdfs # clone, install, build, symlink into node_modules
271
- linked localize # report what is localized (same as --list)
272
- linked localize --list --check # exit 1 when something recorded is not actually linked
273
- linked localize --relink # recreate the recorded symlinks — run this from postinstall
274
- linked localize @_linked/rdfs --adopt # link a checkout already in packages-local/_linked-rdfs, no clone
275
- linked localize --reinstall @_linked/rdfs # npm install inside the checkout, then prune it
276
- linked localize --ensure # remove checkouts' own copies of what the app provides; exit 0 (no names)
277
- linked delocalize @_linked/rdfs # unlink and forget, keeping the checkout
278
- linked delocalize --purge # undo everything and delete the checkouts
274
+ linked localize @_linked/rdfs # clone, npm install at the app root, build
275
+ linked localize @_linked/rdfs --adopt # the same for a checkout already in packages-local/_linked-rdfs
276
+ linked localize # checkouts, parked checkouts and drift (same as --list)
277
+ linked localize --list --check # print only the drift; exit 1 when there is any
278
+ linked delocalize @_linked/rdfs # park it in .packages-local.parked/, back to the registry copy
279
+ linked localize --parked # take every parked checkout back
280
+ linked delocalize @_linked/rdfs --purge # delete it instead (exit 6 on unsaved work on any branch, a stash, or git failing)
279
281
  ```
280
282
 
281
- Checkouts go in `packages-local/` under the npm name with the scope flattened:
282
- `@_linked/rdfs` → `packages-local/_linked-rdfs`. `--list` also flags a directory there that
283
- `local-packages.json` does not record (`UNRECORDED`).
284
-
285
- **One copy of each package the app provides.** A checkout's own install leaves its own copies of
286
- what it depends on, and Node loads those instead of the app's. One rule says what the app provides:
287
- the localized packages, plus every runtime dependency (`dependencies`, `peerDependencies`,
288
- `optionalDependencies`) a localized checkout declares that the app has at a version satisfying
289
- every localized range, plus `react` and `react-dom`. After every install in a checkout and on every
290
- `--relink`, each checkout's own copies of its siblings, of react/react-dom and of its own runtime
291
- dependencies in that set are removed — unless a range that loads the copy misses the app's version,
292
- or removing it would change what its own dependencies resolve to (kept copies are summarised per
293
- checkout; `--list` says why). `linked start`, `script`, `call` and `build-all` re-check first (tens
294
- of milliseconds; one line on stderr when it removes something), and `createViteConfig` derives
295
- Vite's `resolve.dedupe` from the same rule. `--no-prune` at localize/adopt is recorded
296
- (`"prune": false`) and leaves that checkout's `node_modules` as npm left them from then on.
297
-
298
- Localize used to be the separate `@_linked/localize` package (binary `linked-localize`), which is
299
- being retired; `linked localize` replaces it.
283
+ The first `linked localize` in an app sets it up — `"packages-local/*"` in `workspaces`, the
284
+ lock-at-rest hook in `postinstall` and `dependencies`, `packages-local/` and
285
+ `.packages-local.parked/` in `.gitignore`, and the matching lockfile (made before anything is
286
+ cloned) — so `git status` then shows exactly `package.json`, `package-lock.json` and `.gitignore`
287
+ to commit ([details](docs/localize.md#set-up)). Run it anywhere in the app: it acts on the topmost
288
+ directory whose `package.json` has `workspaces`. It also writes git hooks into the clone:
289
+ `pre-commit` refuses a manifest that names a checkout, and `post-merge`/`post-checkout` run
290
+ `npm install` when dependencies changed. They work for an app below the git top level; under
291
+ `core.hooksPath` (husky) localize writes nothing and prints the line to add to each hook. Use
292
+ `npm install`, not `npm ci`, while checkouts are present.
293
+
294
+ **Drift.** npm links a checkout whatever its version. `--list` and the check before `linked start`,
295
+ `script`, `call` and `build-all` report a range a checkout misses, a second copy of a localized
296
+ package, a nested copy of `@_linked/core`/`react`/`server-utils`, and a checkout that is gone.
300
297
 
301
298
  ### Registry / dev utilities
302
299
 
@@ -362,6 +359,17 @@ npm run build
362
359
 
363
360
  Dual ESM + CJS build via `tsconfig-to-dual-package`. Sources in `src/`, output in `lib/esm/` and `lib/cjs/`.
364
361
 
362
+ ### Windows
363
+
364
+ The cli is written to run on Windows but nobody runs it there yet, so treat it as untested. It
365
+ starts `npm`, `npx` and `node_modules/.bin` shims through one helper (`src/utils/spawn.ts`: npm's
366
+ own `npm-cli.js` when npm started the cli, else the `.cmd` through `cmd.exe` with quoted
367
+ arguments), links directories with junctions, imports files by `file:` URL and keeps CRLF in the
368
+ files it edits. Known limits: commands given as one string (`linked all <cmd>`, a `--build`) run
369
+ in `cmd.exe` as written, so they must not use POSIX shell syntax; and this package's own
370
+ `test:template` script sets an environment variable the POSIX way.
371
+ For `linked localize`, see [docs/localize.md](docs/localize.md#windows).
372
+
365
373
  ### Templates
366
374
 
367
375
  Templates live in `defaults/`:
@@ -0,0 +1,169 @@
1
+ // Drift -- what npm is silent about once checkouts are workspace members.
2
+ //
3
+ // npm links the app's dependency on a localized package to its checkout by name, whatever the
4
+ // version; when one checkout misses another's range it nests a registry copy that stays after the
5
+ // range is reverted; and `npm ci` exits 0 with a dangling link when a checkout directory is gone.
6
+ // Each of those loads code nobody meant to load, so this reports them:
7
+ //
8
+ // - `second-copy`: another copy of a localized package, besides its checkout;
9
+ // - `singleton-copy`: a nested copy of a package the app must load once;
10
+ // - `range-miss`: a declared range a checkout's version does not satisfy;
11
+ // - `missing-dir`: npm installed a checkout that is no longer there.
12
+ //
13
+ // It reads npm's hidden lockfile (`node_modules/.package-lock.json`): the committed
14
+ // `package-lock.json` is restored to the registry one after every install (lock-at-rest.cjs) and
15
+ // does not describe the linked tree. Ranges come from the live manifests, because the hidden
16
+ // lockfile lags an edit until the next install. It only reports; nothing here fails a command.
17
+ //
18
+ // The one implementation: lock-at-rest.cjs requires it after an install, and the cli's
19
+ // src/localize/drift.ts loads it (and its range check) for `linked localize --list` and the check
20
+ // before `linked start`.
21
+ // Plain, unbuilt CommonJS with Node built-ins only; `semver` is used when it can be required from
22
+ // here (the cli depends on it), and without it no range is checked.
23
+ 'use strict';
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const {
28
+ LOCAL_DIR,
29
+ readJson,
30
+ trackedGlobs,
31
+ expandGlobs,
32
+ localMembers,
33
+ } = require('./manifests.cjs');
34
+
35
+ let semver = null;
36
+ try {
37
+ semver = require('semver');
38
+ } catch {
39
+ semver = null;
40
+ }
41
+
42
+ // One copy each, app-wide, localized or not: they hold registries of classes (or React's
43
+ // context), so a second copy splits the app in two.
44
+ const SINGLETONS = Object.freeze([
45
+ '@_linked/core',
46
+ '@_linked/react',
47
+ '@_linked/server-utils',
48
+ ]);
49
+
50
+ const DEP_FIELDS = [
51
+ 'dependencies',
52
+ 'devDependencies',
53
+ 'peerDependencies',
54
+ 'optionalDependencies',
55
+ ];
56
+
57
+ // What an app without `workspaces` is read with: the layout linked apps use.
58
+ const DEFAULT_GLOBS = ['packages/*'];
59
+
60
+ /**
61
+ * Does `version` satisfy `range`? null means "cannot tell" -- a dist-tag, `file:`, `npm:`,
62
+ * `workspace:`, a git URL, a version that is not one, or no `semver` to ask -- and drift then
63
+ * reports nothing: npm links the checkout for such a range anyway. npm's own `semver` decides, so
64
+ * prereleases follow npm's rule.
65
+ */
66
+ function satisfies(version, range) {
67
+ if (!semver || typeof version !== 'string' || typeof range !== 'string')
68
+ return null;
69
+ if (semver.valid(version) === null) return null;
70
+ if (semver.validRange(range) === null) return null;
71
+ return semver.satisfies(version, range);
72
+ }
73
+
74
+ /** Every drift in the installed tree, in a stable order. No install yet means none. */
75
+ function driftProblems(appRoot) {
76
+ const lock = readJson(
77
+ path.join(appRoot, 'node_modules', '.package-lock.json'),
78
+ );
79
+ const packages =
80
+ lock && typeof lock.packages === 'object' && lock.packages !== null
81
+ ? lock.packages
82
+ : null;
83
+ if (!packages) return [];
84
+
85
+ const members = localMembers(appRoot);
86
+ const byName = new Map(members.map((m) => [m.name, m]));
87
+ const problems = [];
88
+
89
+ const memberKey = new RegExp(`^${LOCAL_DIR}/[^/]+$`);
90
+ const keys = Object.keys(packages).sort();
91
+ for (const key of keys) {
92
+ if (
93
+ memberKey.test(key) &&
94
+ !fs.existsSync(path.join(appRoot, key, 'package.json'))
95
+ )
96
+ problems.push({kind: 'missing-dir', dir: key});
97
+ }
98
+
99
+ for (const key of keys) {
100
+ const m = key.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/);
101
+ if (!m) continue;
102
+ const name = m[1];
103
+ const entry = packages[key] || {};
104
+ const version = typeof entry.version === 'string' ? entry.version : '';
105
+ const member = byName.get(name);
106
+ if (member) {
107
+ // The checkout itself, reached through a link, is the one copy.
108
+ if (entry.link === true && entry.resolved === member.dir) continue;
109
+ problems.push({kind: 'second-copy', name, version, at: key});
110
+ } else if (SINGLETONS.includes(name) && key !== `node_modules/${name}`) {
111
+ problems.push({kind: 'singleton-copy', name, version, at: key});
112
+ }
113
+ }
114
+
115
+ for (const importer of importers(appRoot, members)) {
116
+ const pkg = readJson(path.join(appRoot, importer, 'package.json'));
117
+ if (!pkg) continue;
118
+ for (const field of DEP_FIELDS) {
119
+ const declared = pkg[field];
120
+ if (!declared || typeof declared !== 'object') continue;
121
+ for (const [name, range] of Object.entries(declared)) {
122
+ const member = byName.get(name);
123
+ if (!member || typeof range !== 'string') continue;
124
+ // null -- a tag, `file:`, `workspace:` -- is not a miss: npm links the checkout anyway.
125
+ if (satisfies(member.version, range) !== false) continue;
126
+ problems.push({
127
+ kind: 'range-miss',
128
+ name,
129
+ version: member.version,
130
+ dir: member.dir,
131
+ range,
132
+ field,
133
+ importer: importer === '.' ? '<root>' : importer,
134
+ });
135
+ }
136
+ }
137
+ }
138
+ return problems;
139
+ }
140
+
141
+ /**
142
+ * The app, its tracked members (every `workspaces` glob but the checkouts') and its checkouts:
143
+ * everything whose ranges npm resolves.
144
+ */
145
+ function importers(appRoot, members) {
146
+ const tracked = expandGlobs(
147
+ appRoot,
148
+ trackedGlobs(appRoot, DEFAULT_GLOBS),
149
+ ).filter((dir) => dir !== LOCAL_DIR && !dir.startsWith(`${LOCAL_DIR}/`));
150
+ return ['.', ...tracked, ...members.map((m) => m.dir)];
151
+ }
152
+
153
+ /** One line, for `--list`, the check before `start` and friends, and lock-at-rest. */
154
+ function describeDrift(d) {
155
+ switch (d.kind) {
156
+ case 'second-copy':
157
+ return `second copy of localized ${d.name}@${d.version} at ${d.at}`;
158
+ case 'singleton-copy':
159
+ return `nested copy of ${d.name}@${d.version} at ${d.at} — the app should load one`;
160
+ case 'range-miss':
161
+ return `${d.name}@${d.version} (${d.dir}) does not satisfy ${d.field} "${d.range}" of ${d.importer}`;
162
+ case 'missing-dir':
163
+ return `${d.dir} is installed but its checkout is gone — run npm install`;
164
+ default:
165
+ return `unknown drift ${JSON.stringify(d)}`;
166
+ }
167
+ }
168
+
169
+ module.exports = {driftProblems, describeDrift, satisfies};
@@ -0,0 +1,170 @@
1
+ // The git hooks `linked localize` writes into a clone (src/localize/git-hooks.ts) all hand over to
2
+ // node node_modules/@_linked/cli/hooks/git-hook.cjs <hook> [git's arguments]
3
+ // from the app directory, which may sit below the git top level. Plain, unbuilt CommonJS with Node
4
+ // built-ins only, like lock-at-rest.cjs: a fresh cli checkout without lib/ must still run it.
5
+ //
6
+ // - pre-commit: refuse a staged package.json or package-lock.json that lists a checkout
7
+ // ("packages-local/<dir>"); the glob "packages-local/*" itself is the committed setup. When git
8
+ // cannot show what is staged, it refuses too: a guard that cannot look does not let things by.
9
+ // - post-merge, post-checkout: `npm install` when the dependency manifests changed, so the
10
+ // checkouts are linked again on top of the new lockfile. Nothing at all when this work tree has
11
+ // no checkouts (hooks are shared by every worktree of a repository). Never fails the git command.
12
+ 'use strict';
13
+ const {execFileSync, spawnSync} = require('child_process');
14
+
15
+ const {LEAK, localMembers, manifestPathspecs} = require('./manifests.cjs');
16
+ const {npmCommand} = require('./platform.cjs');
17
+
18
+ const MANIFESTS = ['package.json', 'package-lock.json'];
19
+ const NO_COMMIT = /^0+$/;
20
+
21
+ // An app's lockfile is often over a megabyte, execFileSync's default buffer.
22
+ const MAX_BUFFER = 256 * 1024 * 1024;
23
+
24
+ // The app directory: the hook shim starts this from there, and npm and every pathspec below are
25
+ // relative to it.
26
+ const root = process.cwd();
27
+
28
+ function git(...args) {
29
+ return execFileSync('git', args, {
30
+ cwd: root,
31
+ encoding: 'utf8',
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ maxBuffer: MAX_BUFFER,
34
+ });
35
+ }
36
+
37
+ const errorText = (e) =>
38
+ String((e && e.stderr) || (e && e.message) || e).trim();
39
+ const BYPASS = ' (git commit --no-verify skips this check.)\n';
40
+
41
+ function preCommit() {
42
+ let staged;
43
+ try {
44
+ // Added, copied, modified, renamed -- not deleted: a staged deletion lists no checkout.
45
+ staged = git(
46
+ 'diff',
47
+ '--cached',
48
+ '--name-only',
49
+ '--diff-filter=d',
50
+ '--relative',
51
+ ).split('\n');
52
+ } catch (e) {
53
+ process.stderr.write(
54
+ `pre-commit: refusing: cannot list the staged files: ${errorText(e)}\n${BYPASS}`,
55
+ );
56
+ return 1;
57
+ }
58
+ let refused = 0;
59
+ for (const file of MANIFESTS) {
60
+ if (!staged.includes(file)) continue;
61
+ let content;
62
+ try {
63
+ content = git('show', `:./${file}`);
64
+ } catch (e) {
65
+ process.stderr.write(
66
+ `pre-commit: refusing ${file}: cannot read the staged copy: ${errorText(e)}\n${BYPASS}`,
67
+ );
68
+ refused = 1;
69
+ continue;
70
+ }
71
+ if (!LEAK.test(content)) continue;
72
+ process.stderr.write(
73
+ `pre-commit: refusing ${file}: it references packages-local/ (written while packages were localized).\n`,
74
+ );
75
+ refused = 1;
76
+ }
77
+ if (refused) {
78
+ process.stderr.write(
79
+ ' To change dependencies: npx linked delocalize, npm install <dep>, commit, then npx linked localize --parked.\n',
80
+ );
81
+ }
82
+ return refused;
83
+ }
84
+
85
+ /** `npm install` when the dependency manifests differ between two commits. Always 0. */
86
+ function installIfChanged(oldRev, newRev) {
87
+ if (!oldRev || !newRev || oldRev === newRev) return 0;
88
+ if (NO_COMMIT.test(oldRev)) return 0; // a fresh clone: the developer runs npm ci
89
+ if (!localMembers(root).length) return 0; // no checkouts here: npm ci / npm install as usual
90
+ // `git diff --quiet` exits 1 when there are differences; anything else is "cannot tell".
91
+ const files = [...manifestPathspecs(root), 'package-lock.json'];
92
+ const diff = spawnSync(
93
+ 'git',
94
+ ['diff', '--quiet', oldRev, newRev, '--', ...files],
95
+ {cwd: root, stdio: 'ignore'},
96
+ );
97
+ if (diff.status !== 1) return 0;
98
+
99
+ process.stderr.write(
100
+ `[git hook] dependency manifests changed (${oldRev.slice(0, 9)}..${newRev.slice(0, 9)}): npm install\n`,
101
+ );
102
+ const start = Date.now();
103
+ const npm = npmCommand(['install', '--no-audit', '--no-fund']);
104
+ const r = spawnSync(npm.command, npm.args, {
105
+ cwd: root,
106
+ stdio: 'inherit',
107
+ env: npmEnv(),
108
+ shell: npm.shell,
109
+ });
110
+ if (r.status !== 0) {
111
+ process.stderr.write('[git hook] npm install FAILED - run it by hand\n');
112
+ return 0;
113
+ }
114
+ process.stderr.write(
115
+ `[git hook] npm install done in ${Math.round((Date.now() - start) / 1000)}s\n`,
116
+ );
117
+ return 0;
118
+ }
119
+
120
+ // git exports the variables that locate this repository to its hooks; npm would hand them to every
121
+ // checkout's lifecycle scripts, and a `git` there would act on the app's repository, not its own.
122
+ function npmEnv() {
123
+ const env = {...process.env};
124
+ for (const key of [
125
+ 'GIT_DIR',
126
+ 'GIT_WORK_TREE',
127
+ 'GIT_INDEX_FILE',
128
+ 'GIT_COMMON_DIR',
129
+ 'GIT_PREFIX',
130
+ 'GIT_OBJECT_DIRECTORY',
131
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
132
+ ])
133
+ delete env[key];
134
+ return env;
135
+ }
136
+
137
+ function revParse(rev) {
138
+ try {
139
+ return git('rev-parse', '--verify', '--quiet', rev).trim();
140
+ } catch {
141
+ return '';
142
+ }
143
+ }
144
+
145
+ function main(argv) {
146
+ const [hook, ...args] = argv;
147
+ switch (hook) {
148
+ case 'pre-commit':
149
+ return preCommit();
150
+ case 'post-merge':
151
+ return installIfChanged(revParse('ORIG_HEAD'), revParse('HEAD'));
152
+ case 'post-checkout':
153
+ // $3 is 1 for a branch checkout and 0 for a file checkout -- lock-at-rest's own restore of
154
+ // package-lock.json is one, and must not start another install.
155
+ if (args[2] !== '1') return 0;
156
+ return installIfChanged(args[0], args[1]);
157
+ default:
158
+ return 0;
159
+ }
160
+ }
161
+
162
+ let code = 0;
163
+ try {
164
+ code = main(process.argv.slice(2));
165
+ } catch (e) {
166
+ process.stderr.write(
167
+ `[git hook] skipped: ${e && e.message ? e.message : e}\n`,
168
+ );
169
+ }
170
+ process.exit(code);
@@ -0,0 +1,63 @@
1
+ // The `sh` that a git hook written by `linked localize` runs: it hands over to git-hook.cjs in the
2
+ // installed cli. src/localize/git-hooks.ts writes it into hook files; lock-at-rest.cjs prints it
3
+ // for a hook file that is not ours. One copy, so the two never drift apart.
4
+ // Plain, unbuilt CommonJS with Node built-ins only, like the other files in hooks/.
5
+ 'use strict';
6
+
7
+ // What marks a hook file `linked localize` wrote.
8
+ const HOOK_MARKER = '# managed by linked localize';
9
+ // The script every hand-over runs, relative to the app: in our hook files and in the line a
10
+ // developer adds to their own.
11
+ const HOOK_SCRIPT = 'node_modules/@_linked/cli/hooks/git-hook.cjs';
12
+
13
+ /** `s` as one single-quoted `sh` word: survives spaces, `$`, quotes and backticks. */
14
+ function shQuote(s) {
15
+ return `'${s.replace(/'/g, `'\\''`)}'`;
16
+ }
17
+
18
+ /**
19
+ * The commands that run git-hook.cjs for `hook` from the app at `prefix` (relative to the git top
20
+ * level, `/`-terminated or empty). Each is one `sh` command, so they can be lines of a file or
21
+ * `; `-joined in a subshell.
22
+ */
23
+ function hookCommands(hook, prefix) {
24
+ const dir = prefix
25
+ ? `dir="$(git rev-parse --show-toplevel)"/${shQuote(prefix.replace(/\/$/, ''))}`
26
+ : 'dir="$(git rev-parse --show-toplevel)"';
27
+ return [
28
+ dir,
29
+ `script="$dir/${HOOK_SCRIPT}"`,
30
+ '{ [ -f "$script" ] && command -v node >/dev/null 2>&1; } || exit 0',
31
+ // git starts a hook at the top level. With GIT_DIR set, git takes the
32
+ // current directory for the top level unless GIT_WORK_TREE says otherwise,
33
+ // and a relative path is relative to here: both stop holding after the cd.
34
+ '[ -z "${GIT_DIR-}" ] || [ -n "${GIT_WORK_TREE-}" ] || export GIT_WORK_TREE="$PWD"',
35
+ // Git for Windows' sh: an absolute path may also be `C:/...` or `C:\...`.
36
+ 'for v in GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE; do eval "p=\\${$v-}"; case "$p" in ""|/*|[A-Za-z]:*) ;; *) export "$v=$PWD/$p" ;; esac; done',
37
+ 'cd "$dir" || exit 0',
38
+ `exec node "$script" ${hook} "$@"`,
39
+ ];
40
+ }
41
+
42
+ /**
43
+ * The same hand-over as one `sh` line, in a subshell, for a hook file another tool or person owns.
44
+ * A subshell's failure does not stop a plain `#!/bin/sh` script, so `|| exit $?` passes it on:
45
+ * a refusing pre-commit refuses wherever in the file the line sits. post-merge and post-checkout
46
+ * always exit 0, so the rest of their file still runs.
47
+ */
48
+ function hookLine(hook, prefix) {
49
+ return `(${hookCommands(hook, prefix).join('; ')}) || exit $?`;
50
+ }
51
+
52
+ /** Does this hook file run ours: written by localize, or holding the line added by hand? */
53
+ function runsOurHook(text) {
54
+ return text.includes(HOOK_MARKER) || text.includes(HOOK_SCRIPT);
55
+ }
56
+
57
+ module.exports = {
58
+ HOOK_MARKER,
59
+ HOOK_SCRIPT,
60
+ hookCommands,
61
+ hookLine,
62
+ runsOurHook,
63
+ };