@_linked/cli 1.46.2 → 1.47.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.
Files changed (167) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +48 -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 +169 -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 +49 -189
  88. package/lib/esm/localize/list.js.map +1 -1
  89. package/lib/esm/localize/localize.d.ts +46 -62
  90. package/lib/esm/localize/localize.d.ts.map +1 -1
  91. package/lib/esm/localize/localize.js +411 -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,112 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.47.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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
8
+ member through the app's `"packages-local/*"` glob, so npm links it and dedupes everything below
9
+ it; nothing is installed inside a checkout, pruned or symlinked by hand any more. A lock-at-rest
10
+ hook keeps the committed `package-lock.json` clean while checkouts are linked, git hooks stop a
11
+ leaked lockfile from being committed, and a drift report says when the linked tree is not what
12
+ the app declares. The full guide is `docs/localize.md`.
13
+
14
+ **To adopt it**
15
+
16
+ 1. Update `@_linked/cli`.
17
+ 2. Run `npx linked localize <pkg>` once. Its first run sets the app up and prints what changed:
18
+ `"packages-local/*"` in `workspaces`, `postinstall` and `dependencies` scripts that run the
19
+ lock-at-rest hook, `packages-local/` and `.packages-local.parked/` in `.gitignore`, and the
20
+ matching `package-lock.json`. Commit exactly those three files (`package.json`,
21
+ `package-lock.json`, `.gitignore`); `git status` shows nothing else. An app that already has the
22
+ earlier unguarded hook command is upgraded in place.
23
+ 3. Remove `linked localize --ensure` from `pre*` scripts and `--relink` from `postinstall` (the
24
+ set-up replaces it).
25
+ 4. Migrating checkouts made by the old localize: delete `local-packages.json` and each checkout's
26
+ own `node_modules`, then take them all in with one `npx linked localize <names…> --adopt`. See
27
+ "Migrating from the old localize" in `docs/localize.md`.
28
+
29
+ The hook scripts run through a guard that does nothing where the cli is not installed, so
30
+ `npm ci --omit=dev` (Docker, production) is unaffected. `npm ci` itself refuses while checkouts are
31
+ present; use `npm install` then.
32
+
33
+ **Commands and flags**
34
+
35
+ - **Changed:** `linked delocalize [names…]` (no names: every checkout) now parks checkouts in
36
+ `.packages-local.parked/` and reinstalls the registry copies itself;
37
+ `linked localize <name>` or the new `linked localize --parked` takes them back. `--purge`
38
+ deletes instead, and refuses unless the work is saved elsewhere: uncommitted changes, commits no
39
+ remote has (on any branch or a detached `HEAD`), a stash, a checkout without `.git` or any
40
+ failing git call all refuse; `--force` overrides.
41
+ - **New:** `--subdir` clones a monorepo whole into `packages-local/.repos/` and links the package
42
+ inside it.
43
+ - **New:** `linked localize --list --check` prints only the drift and exits 1 when there is any,
44
+ for CI.
45
+ - **Removed:** `--reinstall`, `--no-prune` and `localize --force`, with pruning and the Vite dedupe
46
+ localize used to derive. `--dir` accepts only `packages-local` (exit 2 otherwise).
47
+ - **Deprecated:** `--relink` and `--ensure` do nothing and exit 0, so existing scripts keep
48
+ installing. Remove them.
49
+ - `local-packages.json` is no longer read; the record is the directories in `packages-local/`.
50
+ - Every command acts on the app root: the nearest directory whose `package.json` has `workspaces`,
51
+ climbing further only through ancestors whose globs match it. They work from inside a member, and
52
+ a git worktree nested in the app stays its own app.
53
+
54
+ **Exit codes.** 1: `--list --check` found drift. 2: bad command line. 6: any guard refusal,
55
+ including a refused `--purge` (was 5) and an `overrides` key naming a localized package (npm
56
+ refuses those with `EOVERRIDE`; localize names the key and changes nothing). 7: `npm install` at
57
+ the app root failed. 3 (unusable record file) and 8 (manifests changed during the run) are retired
58
+ and not reused.
59
+
60
+ **Git hooks.** localize writes `pre-commit` (refuses a staged `package.json` or
61
+ `package-lock.json` that lists a checkout, and refuses when git cannot show what is staged) and
62
+ `post-merge`/`post-checkout` (`npm install` when the dependency manifests changed; nothing in a
63
+ work tree without checkouts). They work for an app below the git top level and in linked
64
+ worktrees (which share them; localize says so). Under `core.hooksPath` (husky) nothing is written:
65
+ localize prints the one line to add to each hook, which refuses wherever in the file it sits. A
66
+ hook file you wrote is left alone. A clone that has checkouts but no hooks gets a one-line notice
67
+ from lock-at-rest naming the command that writes them.
68
+
69
+ **Lock-at-rest.** After every install it restores the committed (or deliberately staged)
70
+ `package-lock.json`. While `package.json` differs from `HEAD` it leaves the lockfile alone and
71
+ warns that it must not be committed: change dependencies with `linked delocalize` first.
72
+
73
+ **Drift.** Reported by `--list`, by lock-at-rest after an install that changed the tree, and
74
+ before `start`, `script`, `call` and `build-all`: a range a checkout misses, a second copy of a
75
+ localized package, a nested copy of `@_linked/core`, `@_linked/react` or `@_linked/server-utils`,
76
+ and a checkout npm installed that is gone. It only reports, except under `--list --check`.
77
+
78
+ **`build-all`** builds the cli's own dependencies (a localized `@_linked/core`) first, then the
79
+ cli alone, then the rest; a localized cli builds with its own `npm run build`.
80
+
81
+ **Windows (written for it, not yet run there).** `npm`, `npx` and `.bin` shims start through one
82
+ helper (`node npm-cli.js` when npm started the cli, else the `.cmd` through `cmd.exe` with every
83
+ argument quoted), in the commands and in the `post-merge`/`post-checkout` hook. `--subdir` links
84
+ are junctions; files are imported by `file:` URL; POSIX-only shell use (`cd <dir> &&` chains,
85
+ `/dev/tty`, single-quoted strings, `chmod` in the cli's own build) is gone; edited
86
+ `package.json`, `.gitignore`, `.npmrc` and source files keep CRLF. README "Windows" lists what is
87
+ left.
88
+
89
+ **Removed deep imports** (reachable before through the `@_linked/cli/*` subpath exports; no known
90
+ consumer):
91
+
92
+ - `vite-config`: `localizedDedupe`;
93
+ - `utils/createPackageLocation`: `ensureRelinkPostinstall`, `ensureRelinkHooks`, `RELINK_COMMAND`;
94
+ - `installed-packages`: `readInstalledPkgSync`;
95
+ - `lifecycle`: `BuildToolOrder.buildToolFirst` (the type now has `pre` and `start`);
96
+ - `commands/localize`: `pruneOptions`;
97
+ - `localize/index`: `relink`, `pruneProvided`, `shouldPrune`, `providedPackages`, `ensure`,
98
+ `reinstall`, `checkOneCopy`, `readManifest`, `writeManifest`, `manifestPath`,
99
+ `MANIFEST_FILENAME`, `SCHEMA_VERSION`, `DEFAULT_DIR`, `EXIT_BAD_FILE`, `EXIT_MANIFEST_DIRTY`, and
100
+ the types `Manifest`, `ManifestEntry`, `PruneOptions`, `PruneScope`, `Provided`, `ProvidedSkip`
101
+ (`satisfies` stays, now drift's range check);
102
+ - `localize/localize`: `forEachName`, `installLinkAndRecord`, `guardForeignLink`, `declaredRange`,
103
+ `assertManifestsUntouched`, and the types `OneName`, `OneResult`;
104
+ - `localize/fsops`: `linkTarget`, `writeLink`, `scanLinks`, `MANIFEST_FILES`,
105
+ `snapshotManifests`, `diffManifests`, `isGitCheckout`;
106
+ - `localize/errors`: `EXIT_BAD_FILE`, `EXIT_MANIFEST_DIRTY`;
107
+ - whole modules: `localized-dedupe`, `utils/relinkHooks`, `localize/prune`, `localize/ensure`,
108
+ `localize/relink`, `localize/provided`, `localize/manifest`, `localize/one-copy-hook`.
109
+
3
110
  ## 1.46.2
4
111
 
5
112
  ### 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,41 @@ 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.
297
+
298
+ `--relink` and `--ensure` are deprecated no-ops that exit 0; `local-packages.json` is no longer
299
+ read. Localize used to be the separate `@_linked/localize` package (binary `linked-localize`),
300
+ which is being retired; `linked localize` replaces it.
300
301
 
301
302
  ### Registry / dev utilities
302
303
 
@@ -362,6 +363,17 @@ npm run build
362
363
 
363
364
  Dual ESM + CJS build via `tsconfig-to-dual-package`. Sources in `src/`, output in `lib/esm/` and `lib/cjs/`.
364
365
 
366
+ ### Windows
367
+
368
+ The cli is written to run on Windows but nobody runs it there yet, so treat it as untested. It
369
+ starts `npm`, `npx` and `node_modules/.bin` shims through one helper (`src/utils/spawn.ts`: npm's
370
+ own `npm-cli.js` when npm started the cli, else the `.cmd` through `cmd.exe` with quoted
371
+ arguments), links directories with junctions, imports files by `file:` URL and keeps CRLF in the
372
+ files it edits. Known limits: commands given as one string (`linked all <cmd>`, a `--build`) run
373
+ in `cmd.exe` as written, so they must not use POSIX shell syntax; and this package's own
374
+ `test:template` script sets an environment variable the POSIX way.
375
+ For `linked localize`, see [docs/localize.md](docs/localize.md#windows).
376
+
365
377
  ### Templates
366
378
 
367
379
  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
+ };