rman 2.23.0 → 2.24.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.
@@ -1,3 +1,4 @@
1
+ import type { PublishFilesKeys } from '../../../core/interfaces/publish-target.js';
1
2
  import type { ConfigValue, RmanConfig, ScopedVars } from '../../../interfaces/rman-config.interface.js';
2
3
  import type { CiService } from './services/ci.service.js';
3
4
  /**
@@ -26,7 +27,7 @@ export declare namespace RmanNodeConfig {
26
27
  */
27
28
  interface NpmPublishOptions extends NpmPublishOptionsKeys, ScopedVars {
28
29
  }
29
- interface NpmPublishOptionsKeys {
30
+ interface NpmPublishOptionsKeys extends PublishFilesKeys {
30
31
  /** Where this package's publishable output lives, relative to its own directory (e.g.
31
32
  * `"build"`). Per-package cascaded, so a root `"[*]"` block can say it once for the whole
32
33
  * repository instead of repeating `publishConfig.directory` in every `package.json` - which
@@ -226,12 +226,14 @@ async function cleanGlobs(dirname, include, exclude, dryRun) {
226
226
  * **The build directory is excluded by name from the config, never as the literal `build`** - see
227
227
  * `buildDirOf`. Its contents are the *point* of a build and are removed by `clean.include` when the
228
228
  * repository asks, not by a rule about stray files. */
229
- async function cleanTsArtifacts(pkg, dryRun) {
229
+ async function cleanTsArtifacts(pkg, exclude, dryRun) {
230
230
  const dirname = pkg.dirname;
231
231
  const buildDir = buildDirOf(pkg);
232
232
  const files = await fg('**/*.{js,js.map,d.ts}', {
233
233
  cwd: dirname,
234
- ignore: ['**/node_modules/**', `${buildDir}/**`, `**/${buildDir}/**`],
234
+ /** `clean.exclude` protects a file from this sweep too - the one way to keep a file the rules
235
+ * below would read as output. */
236
+ ignore: ['**/node_modules/**', `${buildDir}/**`, `**/${buildDir}/**`, ...exclude],
235
237
  onlyFiles: true,
236
238
  dot: true,
237
239
  absolute: true,
@@ -264,17 +266,24 @@ async function cleanTsArtifacts(pkg, dryRun) {
264
266
  * - **A `.d.ts` always needs its `.ts`/`.tsx` beside it**, everywhere including `src` - a
265
267
  * hand-written declaration is an ordinary thing to keep in a source tree, and this is the rule
266
268
  * that has always been here.
267
- * - **A `.js`/`.js.map` under `src`/`test` goes regardless.** Everything there is TypeScript, so an
268
- * orphan - output whose source was renamed or deleted - is still output, and sweeping it is the
269
- * `ts-cleanup --all` behaviour this replaced.
269
+ * - **A `.js`/`.js.map` under `src`/`test` goes**, unless a `.d.ts` with no `.ts` sits beside the
270
+ * `.js`. Everything else there is TypeScript, so an orphan - output whose source was renamed or
271
+ * deleted - is still output, and sweeping it is the `ts-cleanup --all` behaviour this replaced.
270
272
  * - **A `.js`/`.js.map` anywhere else needs its source beside it**, because out there it could just
271
273
  * as easily be `index.js`, `*.config.js` or `scripts/*.js`, which nobody generated.
272
274
  */
275
+ /* **A `.js` declared by a hand-written `.d.ts` is kept**, because the first rule already reads that
276
+ * `.d.ts` as hand-written - and a declaration nobody generated describes a module nobody compiled.
277
+ * Measured on `panates/hl7v2`: `src/data/hl7-data.js`, a generated plain-JS data module with its
278
+ * `.d.ts` beside it, was deleted by the preset's `rman clean` before every build, and the next step
279
+ * failed with esbuild's `Could not resolve "./data/hl7-data.js"` - an error about the import, far
280
+ * from the cause. The cost, stated: a tsc orphan *pair* (`.js` and `.d.ts` whose `.ts` was renamed)
281
+ * now stays, where before only its `.d.ts` did. */
273
282
  function isBuildOutput(dirname, file) {
274
283
  if (file.endsWith('.d.ts'))
275
284
  return hasTsSource(file, '.d.ts');
276
285
  if (insideTsSourceDir(dirname, file))
277
- return true;
286
+ return !(file.endsWith('.js') && isDeclaredByHand(file));
278
287
  return hasTsSource(file, file.endsWith('.js.map') ? '.js.map' : '.js');
279
288
  }
280
289
  /** Directories where everything is TypeScript, so a compiled file needs no source beside it to be
@@ -293,6 +302,11 @@ function hasTsSource(file, suffix) {
293
302
  const base = file.slice(0, -suffix.length);
294
303
  return fs.existsSync(base + '.ts') || fs.existsSync(base + '.tsx');
295
304
  }
305
+ /** Whether a `.js` has a `.d.ts` beside it and no `.ts`/`.tsx` - a module with a hand-written
306
+ * declaration, not compiled output. */
307
+ function isDeclaredByHand(file) {
308
+ return fs.existsSync(file.slice(0, -'.js'.length) + '.d.ts') && !hasTsSource(file, '.js');
309
+ }
296
310
  /**
297
311
  * Removes TypeScript's incremental-build cache files (`*.tsbuildinfo`) anywhere under `dirname` -
298
312
  * `tsc --build`/`composite`/`incremental` output that `ts-cleanup` never touched, but which can
@@ -316,11 +330,11 @@ async function cleanPackage(pkg, item, panelEnabled, dryRun, logger) {
316
330
  item.status = 'running';
317
331
  item.startedAt = Date.now();
318
332
  try {
333
+ const { include, exclude } = cleanConfig(pkg);
319
334
  item.currentStep = 'ts';
320
- const tsRemoved = await cleanTsArtifacts(pkg, dryRun);
335
+ const tsRemoved = await cleanTsArtifacts(pkg, exclude, dryRun);
321
336
  const buildInfoRemoved = await cleanTsBuildInfo(pkg.dirname, dryRun);
322
337
  item.currentStep = 'glob';
323
- const { include, exclude } = cleanConfig(pkg);
324
338
  const globRemoved = await cleanGlobs(pkg.dirname, include, exclude, dryRun);
325
339
  const removed = [...tsRemoved, ...buildInfoRemoved, ...globRemoved];
326
340
  const verb = dryRun ? 'would rm' : 'rm';
@@ -17,7 +17,7 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { exec } from '../../../../utils/exec.js';
20
- import { GitHelper } from '../../../../utils/git.js';
20
+ import { dirtyReason, filesUnder, GitHelper } from '../../../../utils/git.js';
21
21
  import { filterPackages } from '../../../../utils/package-filter.js';
22
22
  import { isCalendarVersion } from '../../../../utils/release-version.js';
23
23
  import { npmViewPackage } from '../../../publish-targets/npm/npm-view.js';
@@ -197,7 +197,7 @@ export var PublishService;
197
197
  const git = new GitHelper({ cwd: repository.dirname });
198
198
  const packages = filterPackages(repository.getPackages({ toposort: true }), options).filter(pkg => options.shipsHere?.(pkg) ?? true);
199
199
  const dirtyFiles = await git.listDirtyFiles({ absolute: true });
200
- const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
200
+ const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
201
201
  /** Per package, not per run: `resolveRegistry` reads the package's own `publishConfig.registry`
202
202
  * when `--registry` was not given, so the question is asked where the answer lives. The test
203
203
  * seam keeps its `(name, cwd)` shape - a stub answers from a map and has no registry to
@@ -250,7 +250,7 @@ export var PublishService;
250
250
  package: pkg,
251
251
  version: pkg.version,
252
252
  status: options.ignoreDirty ? 'skip' : 'error',
253
- reason: 'uncommitted local changes',
253
+ reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
254
254
  });
255
255
  }
256
256
  else {
@@ -46,6 +46,9 @@ export declare class CloudflarePublishService extends Service {
46
46
  protected workerHas(deploy: CloudflarePublishService.WorkersDeploy): Promise<boolean>;
47
47
  /** Runs the deploy, its output shown on stderr as it comes and kept for the reason if it fails. */
48
48
  protected deploy(deploy: CloudflarePublishService.Deploy): Promise<void>;
49
+ /** `--env <name>` when the Worker deploys to a named wrangler environment - for the deploy and for
50
+ * the versions list, which would otherwise ask about a different Worker. */
51
+ protected envArgs(deploy: CloudflarePublishService.WorkersDeploy): string[];
49
52
  /**
50
53
  * `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
51
54
  * and through `npx` when it is not - so a package that never added it as a dependency still
@@ -84,6 +87,10 @@ export declare namespace CloudflarePublishService {
84
87
  tag: string;
85
88
  /** Absolute path of the wrangler configuration. */
86
89
  config: string;
90
+ /** The wrangler environment, when one is named. */
91
+ env?: string;
92
+ /** `--var` entries. */
93
+ variables: Record<string, string>;
87
94
  }
88
95
  export type Deploy = PagesDeploy | WorkersDeploy;
89
96
  /** One package's outcome - a `PublishTarget.Entry` carrying where it deploys. */
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Service } from '../../../core/classes/service.js';
4
4
  import { targetsOf } from '../../../core/interfaces/publish-target.js';
5
- import { GitHelper } from '../../../utils/git.js';
5
+ import { dirtyReason, filesUnder, GitHelper } from '../../../utils/git.js';
6
6
  import { filterPackages } from '../../../utils/package-filter.js';
7
7
  import { runBin } from '../../../utils/run-bin.js';
8
8
  /** The name this target answers to in `publish.target` and `--target`. */
@@ -34,7 +34,7 @@ export class CloudflarePublishService extends Service {
34
34
  const git = new GitHelper({ cwd: repository.dirname });
35
35
  const packages = filterPackages(repository.getPackages({ toposort: true }), options).filter(pkg => targetsOf(this.app, pkg).some(t => t.name === CLOUDFLARE_TARGET) && !pkg.config.publish?.skip);
36
36
  const dirtyFiles = await git.listDirtyFiles({ absolute: true });
37
- const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
37
+ const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
38
38
  const credentials = this.credentials();
39
39
  const isDeployed = deps.isDeployed ?? ((deploy) => this.isDeployed(deploy));
40
40
  return Promise.all(packages.map(async (pkg) => {
@@ -48,7 +48,11 @@ export class CloudflarePublishService extends Service {
48
48
  }
49
49
  const located = { ...base, deploy, detail: deploy.label };
50
50
  if (isDirty(pkg)) {
51
- return { ...located, status: options.ignoreDirty ? 'skip' : 'error', reason: 'uncommitted local changes' };
51
+ return {
52
+ ...located,
53
+ status: options.ignoreDirty ? 'skip' : 'error',
54
+ reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
55
+ };
52
56
  }
53
57
  if (!credentials) {
54
58
  return {
@@ -91,6 +95,13 @@ export class CloudflarePublishService extends Service {
91
95
  throw new Error('"cloudflare" is a publish target but "publish.cloudflare" is not configured');
92
96
  const marker = `${pkg.name}@${pkg.version}`;
93
97
  if (cf.kind === 'pages') {
98
+ /** Said rather than ignored: `wrangler pages deploy` has no `--env` or `--var`, and a setting
99
+ * that silently does nothing is the one a reader trusts. */
100
+ for (const key of ['env', 'variables']) {
101
+ if (cf[key] !== undefined) {
102
+ throw new Error(`"publish.cloudflare.${key}" is for kind "workers" - Pages takes settings through "files"`);
103
+ }
104
+ }
94
105
  const project = cf.project;
95
106
  if (!project)
96
107
  throw new Error('"publish.cloudflare.project" is required for kind "pages"');
@@ -108,13 +119,16 @@ export class CloudflarePublishService extends Service {
108
119
  ? `"publish.cloudflare.config" names ${declared}, which does not exist`
109
120
  : `kind "workers" needs a wrangler configuration - none of ${this.workerConfigFiles.join(', ')} is in the package`);
110
121
  }
122
+ const env = cf.env || undefined;
111
123
  return {
112
124
  kind: 'workers',
113
125
  pkg,
114
126
  marker,
115
127
  tag: `v${pkg.version}`,
116
128
  config,
117
- label: `workers ${path.relative(pkg.dirname, config)}`,
129
+ env,
130
+ variables: cf.variables ?? {},
131
+ label: `workers ${path.relative(pkg.dirname, config)}${env ? ` (${env})` : ''}`,
118
132
  };
119
133
  }
120
134
  throw new Error(`"publish.cloudflare.kind" must be "pages" or "workers", not ${JSON.stringify(cf.kind)}`);
@@ -154,7 +168,8 @@ export class CloudflarePublishService extends Service {
154
168
  /** A version of the Worker tagged with this version - among the ten most recent wrangler lists. */
155
169
  async workerHas(deploy) {
156
170
  const lines = [];
157
- await this.wrangler(deploy.pkg, ['versions', 'list', '--json', '--config', deploy.config], (line, stream) => {
171
+ const argv = ['versions', 'list', '--json', '--config', deploy.config, ...this.envArgs(deploy)];
172
+ await this.wrangler(deploy.pkg, argv, (line, stream) => {
158
173
  if (stream === 'stdout')
159
174
  lines.push(line);
160
175
  });
@@ -179,7 +194,17 @@ export class CloudflarePublishService extends Service {
179
194
  deploy.marker,
180
195
  '--commit-dirty=true',
181
196
  ]
182
- : ['deploy', '--config', deploy.config, '--tag', deploy.tag, '--message', deploy.marker];
197
+ : [
198
+ 'deploy',
199
+ '--config',
200
+ deploy.config,
201
+ ...this.envArgs(deploy),
202
+ ...Object.entries(deploy.variables).flatMap(([name, value]) => ['--var', `${name}:${value}`]),
203
+ '--tag',
204
+ deploy.tag,
205
+ '--message',
206
+ deploy.marker,
207
+ ];
183
208
  const lines = [];
184
209
  try {
185
210
  await this.wrangler(deploy.pkg, argv, line => {
@@ -194,6 +219,11 @@ export class CloudflarePublishService extends Service {
194
219
  });
195
220
  }
196
221
  }
222
+ /** `--env <name>` when the Worker deploys to a named wrangler environment - for the deploy and for
223
+ * the versions list, which would otherwise ask about a different Worker. */
224
+ envArgs(deploy) {
225
+ return deploy.env ? ['--env', deploy.env] : [];
226
+ }
197
227
  /**
198
228
  * `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
199
229
  * and through `npx` when it is not - so a package that never added it as a dependency still
@@ -1,4 +1,4 @@
1
- import type { PublishTarget } from '../../../core/interfaces/publish-target.js';
1
+ import type { PublishFilesKeys, PublishTarget } from '../../../core/interfaces/publish-target.js';
2
2
  import type { ConfigValue, ScopedVars } from '../../../interfaces/rman-config.interface.js';
3
3
  /**
4
4
  * **`publish.cloudflare.*`** - this target's own config block, declared beside it and reaching
@@ -9,7 +9,7 @@ import type { ConfigValue, ScopedVars } from '../../../interfaces/rman-config.in
9
9
  */
10
10
  export interface CloudflarePublishOptions extends CloudflarePublishOptionsKeys, ScopedVars {
11
11
  }
12
- export interface CloudflarePublishOptionsKeys {
12
+ export interface CloudflarePublishOptionsKeys extends PublishFilesKeys {
13
13
  /**
14
14
  * What the package deploys as: `"pages"` - a directory of static files uploaded to a Pages
15
15
  * project (`wrangler pages deploy`) - or `"workers"` - a Worker, static assets included, as its
@@ -26,6 +26,13 @@ export interface CloudflarePublishOptionsKeys {
26
26
  /** Workers: the wrangler configuration file, relative to the package. Default: the first of
27
27
  * `wrangler.jsonc`, `wrangler.json`, `wrangler.toml` in the package. */
28
28
  config?: ConfigValue<string>;
29
+ /** Workers: the wrangler environment to deploy (`--env`) - one the configuration declares,
30
+ * `staging` or `production`. */
31
+ env?: ConfigValue<string>;
32
+ /** Workers: variables the Worker reads from `env` at run time (`--var NAME:value`), over the
33
+ * configuration's own `vars`. Not for secrets - they show in the dashboard; those are
34
+ * `wrangler secret`'s. Pages takes none from the command line: a file through `files` instead. */
35
+ variables?: ConfigValue<Record<string, string>>;
29
36
  }
30
37
  /**
31
38
  * **Cloudflare, as a publish target** - a static site on Pages, or a Worker with its assets.
@@ -5,8 +5,9 @@ import { promisify } from 'node:util';
5
5
  import { Service } from '../../../core/classes/service.js';
6
6
  import { targetsOf } from '../../../core/interfaces/publish-target.js';
7
7
  import { exec } from '../../../utils/exec.js';
8
- import { GitHelper } from '../../../utils/git.js';
8
+ import { dirtyReason, filesUnder, GitHelper } from '../../../utils/git.js';
9
9
  import { filterPackages } from '../../../utils/package-filter.js';
10
+ import { isCalendarVersion } from '../../../utils/release-version.js';
10
11
  /**
11
12
  * The name this target answers to in `publish.target` and `--target`.
12
13
  *
@@ -42,7 +43,7 @@ export class DockerPublishService extends Service {
42
43
  * where a package ships. */
43
44
  const packages = filterPackages(repository.getPackages({ toposort: true }), options).filter(pkg => targetsOf(this.app, pkg).some(t => t.name === DOCKER_TARGET) && !pkg.config.publish?.skip);
44
45
  const dirtyFiles = await git.listDirtyFiles({ absolute: true });
45
- const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
46
+ const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
46
47
  const imageExists = deps.imageExists ?? defaultImageExists;
47
48
  const entries = new Map();
48
49
  const toCheck = [];
@@ -72,19 +73,22 @@ export class DockerPublishService extends Service {
72
73
  image,
73
74
  detail: image,
74
75
  status: options.ignoreDirty ? 'skip' : 'error',
75
- reason: 'uncommitted local changes',
76
+ reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
76
77
  });
77
78
  continue;
78
79
  }
79
80
  toCheck.push({ pkg, image });
80
81
  }
82
+ /** What the plan prints beside a package is every tag it pushes, so `--dry-run` shows whether a
83
+ * prerelease would move `latest`. */
84
+ const describe = (pkg, image) => `${image}:${imageTags(pkg).join(', ')}`;
81
85
  await Promise.all(toCheck.map(async ({ pkg, image }) => {
82
86
  const exists = await imageExists(image, pkg.version);
83
87
  entries.set(pkg.name, {
84
88
  package: pkg,
85
89
  version: pkg.version,
86
90
  image,
87
- detail: image,
91
+ detail: describe(pkg, image),
88
92
  status: exists ? 'up-to-date' : 'publish',
89
93
  reason: exists ? `registry already has ${image}:${pkg.version}` : 'never published',
90
94
  });
@@ -151,6 +155,40 @@ function resolveImageRef(image, namespaceOverride) {
151
155
  }
152
156
  return `${namespace}/${image}`;
153
157
  }
158
+ /**
159
+ * Every tag an image of `pkg` is pushed under: its version, its floating tag, then
160
+ * `publish.docker.tags`, each once.
161
+ *
162
+ * The floating tag is `latest` for a release and the identifier for a prerelease - `2.0.0-beta.1`
163
+ * goes to `beta`, and one with no word to name it (`2.0.0-1`) to none. A calendar version is a
164
+ * release however its time is spelled.
165
+ */
166
+ /* **Every image used to be pushed as `latest`**, a beta included, so a plain `docker pull` took
167
+ * whatever was pushed last - the mistake npm's `distTagFor` exists to prevent, made on the other
168
+ * registry. Same rule, same scheme questions. */
169
+ function imageTags(pkg) {
170
+ const version = pkg.version;
171
+ const preview = !isCalendarVersion(version) && pkg.versionScheme.isPrerelease(version);
172
+ const floating = preview ? pkg.versionScheme.prereleaseId(version) : 'latest';
173
+ const extra = (pkg.config.publish?.docker?.tags ?? []).filter((t) => typeof t === 'string' && !!t);
174
+ return [...new Set([version, ...(floating ? [floating] : []), ...extra])];
175
+ }
176
+ /** `--secret`'s value: `"$NAME"` reads the environment, anything else is a file in the package. */
177
+ function secretSpec(pkg, id, value) {
178
+ const env = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
179
+ if (env) {
180
+ if (process.env[env[1]] === undefined) {
181
+ throw new Error(`"publish.docker.secrets.${id}" reads ${env[1]}, which is not set`);
182
+ }
183
+ return `id=${id},env=${env[1]}`;
184
+ }
185
+ return `id=${id},src=${path.resolve(pkg.dirname, value)}`;
186
+ }
187
+ function asList(value) {
188
+ if (!value)
189
+ return [];
190
+ return Array.isArray(value) ? value : [value];
191
+ }
154
192
  /** A value of exactly `"$NAME"` expands to `process.env.NAME` (empty string if unset) - anything
155
193
  * else (including a value with `$` only as part of a larger string) is passed through verbatim. */
156
194
  function expandEnvValue(value) {
@@ -182,7 +220,20 @@ async function buildAndPush(repository, entry) {
182
220
  for (const [name, value] of Object.entries(docker.buildArgs ?? {})) {
183
221
  args.push('--build-arg', `${name}="${expandEnvValue(value)}"`);
184
222
  }
185
- args.push('-f', `"${dockerfile}"`, '-t', `"${entry.image}:${entry.version}"`, '-t', `"${entry.image}:latest"`, '--push', '.');
223
+ for (const [id, value] of Object.entries(docker.secrets ?? {}))
224
+ args.push('--secret', `"${secretSpec(pkg, id, value)}"`);
225
+ for (const [name, value] of Object.entries(docker.labels ?? {}))
226
+ args.push('--label', `"${name}=${value}"`);
227
+ if (docker.target)
228
+ args.push('--target', `"${docker.target}"`);
229
+ for (const from of asList(docker.cache?.from))
230
+ args.push('--cache-from', `"${from}"`);
231
+ for (const to of asList(docker.cache?.to))
232
+ args.push('--cache-to', `"${to}"`);
233
+ args.push('-f', `"${dockerfile}"`);
234
+ for (const tag of imageTags(pkg))
235
+ args.push('-t', `"${entry.image}:${tag}"`);
236
+ args.push('--push', '.');
186
237
  /** **Shown as it runs and kept**, so a failure can say why. It ran with `stdio: 'inherit'`, and the
187
238
  * one thing left to report was the exit code - measured on `panates/syncbridge`, the line that
188
239
  * explained the failure sat forty lines above the plan's recap and the recap said
@@ -1,4 +1,4 @@
1
- import type { PublishTarget } from '../../../core/interfaces/publish-target.js';
1
+ import type { PublishFilesKeys, PublishTarget } from '../../../core/interfaces/publish-target.js';
2
2
  import type { ConfigValue, ScopedVars } from '../../../interfaces/rman-config.interface.js';
3
3
  /**
4
4
  * **`publish.docker.*`** - this target's own config block, declared here rather than centrally.
@@ -15,7 +15,7 @@ export interface DockerPublishOptions extends DockerPublishOptionsKeys, ScopedVa
15
15
  /** Every key here is a **value**, so every one is a `ConfigValue` - this target declares no step,
16
16
  * which is what makes the whole block uniform (see `VersionExtraKeys` for the interface where it
17
17
  * is not). */
18
- export interface DockerPublishOptionsKeys {
18
+ export interface DockerPublishOptionsKeys extends PublishFilesKeys {
19
19
  /** DockerHub image name/repository - bare (e.g. `"my-app"`) to be prefixed with
20
20
  * `--docker-namespace`/`DOCKERHUB_NAMESPACE`, or already-namespaced (contains a `/`) to use
21
21
  * verbatim. */
@@ -38,6 +38,26 @@ export interface DockerPublishOptionsKeys {
38
38
  /** A file (relative to the package's own directory) whose contents become the DockerHub repo's
39
39
  * full description, if present. Default `"DOCKER_README.md"`. */
40
40
  readme?: ConfigValue<string>;
41
+ /**
42
+ * `docker buildx build --secret` entries, keyed by the secret's id: a build-time value the image's
43
+ * layers never record, where a `buildArgs` value is visible in its history. `"$NAME"` takes it
44
+ * from `process.env.NAME`; anything else is a file, relative to the package. A Dockerfile reads
45
+ * one with `RUN --mount=type=secret,id=<id>`.
46
+ */
47
+ secrets?: ConfigValue<Record<string, string>>;
48
+ /** Tags pushed besides the version's own and its floating one (`latest`, or a prerelease's
49
+ * identifier) - `"${{ semver.major(pkg.version) }}"` for a major line. */
50
+ tags?: ConfigValue<string[]>;
51
+ /** `docker buildx build --target` - the stage of a multi-stage Dockerfile that becomes the image. */
52
+ target?: ConfigValue<string>;
53
+ /** `--cache-from` / `--cache-to` - where the build reads and writes its layer cache
54
+ * (`type=gha` on GitHub Actions). Each a single value or a list. */
55
+ cache?: ConfigValue<{
56
+ from?: string | string[];
57
+ to?: string | string[];
58
+ }>;
59
+ /** `docker buildx build --label` entries - labels on the image beside the Dockerfile's own. */
60
+ labels?: ConfigValue<Record<string, string>>;
41
61
  }
42
62
  /**
43
63
  * **Docker, as a publish target** - the core's own, and the reason `PublishTarget` is in the core
@@ -1,10 +1,13 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import readline from 'node:readline/promises';
3
4
  import colors from 'ansi-colors';
4
5
  import { shipsTo, unknownTargets } from '../core/interfaces/publish-target.js';
5
6
  import { registerCommand } from '../interfaces/rman-config.interface.js';
6
7
  import { assertAllowedBranch, branchGuardOptions, readBranchGuardOptions } from '../utils/branch-guard.js';
8
+ import { UNCOMMITTED } from '../utils/git.js';
7
9
  import { packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
10
+ import { PublishFiles } from '../utils/publish-files.js';
8
11
  /** Hoisted for `ArgsOf` - see `version.command.ts` and `RmanConfig.ArgsOf` for why. */
9
12
  const COMMAND = 'publish';
10
13
  /**
@@ -109,6 +112,12 @@ const publishCommand = registerCommand(app => {
109
112
  ],
110
113
  handler: async (args) => {
111
114
  await assertAllowedBranch(repository, readBranchGuardOptions(args));
115
+ /** Before anything is planned: a run that died while one target's files were in place left
116
+ * them there, and the next target - or the dirty check - must not see them. */
117
+ const recovered = PublishFiles.recover(repository.dirname);
118
+ if (recovered.length) {
119
+ console.error(colors.yellow(`Put back ${recovered.length} file(s) an interrupted publish had copied in (publish.<target>.files).`));
120
+ }
112
121
  const requested = args.target;
113
122
  /** A target built from what the others publish goes after them - see `publishesLast`. A
114
123
  * stable sort, so registration order still decides among the rest. */
@@ -129,12 +138,27 @@ const publishCommand = registerCommand(app => {
129
138
  for (const target of selected) {
130
139
  plans.set(target, (await target.getPlan(ctx)).filter(entry => shipsTo(entry.package, target)));
131
140
  }
141
+ /** A missing source fails the plan, before anything is published or copied. */
142
+ for (const [target, plan] of plans) {
143
+ for (const [i, entry] of plan.entries()) {
144
+ if (entry.status !== 'publish')
145
+ continue;
146
+ try {
147
+ filesFor(entry.package, target);
148
+ }
149
+ catch (e) {
150
+ plan[i] = { ...entry, status: 'error', reason: e.message };
151
+ }
152
+ }
153
+ }
132
154
  if (args.json) {
133
155
  console.log(JSON.stringify(jsonPlan(plans), undefined, 2));
134
156
  }
135
157
  else {
136
- for (const [target, plan] of plans)
158
+ for (const [target, plan] of plans) {
137
159
  printPlan(plan, target.name);
160
+ printFiles(plan, target);
161
+ }
138
162
  }
139
163
  /** An explicitly requested target that matched nothing is a mistake worth reporting: the run
140
164
  * asked for something by name and silently got an empty plan back. */
@@ -147,7 +171,7 @@ const publishCommand = registerCommand(app => {
147
171
  const all = [...plans.values()].flat();
148
172
  const errors = all.filter(e => e.status === 'error');
149
173
  if (errors.length) {
150
- const allDirty = errors.every(e => e.reason === 'uncommitted local changes');
174
+ const allDirty = errors.every(e => e.reason?.startsWith(UNCOMMITTED));
151
175
  throw logged(allDirty
152
176
  ? `${errors.length} package(s) have uncommitted local changes ` +
153
177
  '(pass --ignore-dirty to exclude them instead of aborting)'
@@ -180,7 +204,15 @@ const publishCommand = registerCommand(app => {
180
204
  waited = true;
181
205
  await waitForPublished(ctx, publishedBy);
182
206
  }
183
- const applied = await target.applyPlan(ctx, plan);
207
+ const files = plan.filter(e => e.status === 'publish').flatMap(e => filesFor(e.package, target));
208
+ const staged = files.length ? PublishFiles.stage(repository.dirname, `[${target.name}]`, files) : undefined;
209
+ let applied;
210
+ try {
211
+ applied = await target.applyPlan(ctx, plan);
212
+ }
213
+ finally {
214
+ staged?.restore();
215
+ }
184
216
  const done = printApplied(applied, plan, target.name);
185
217
  outcomes.push(...done);
186
218
  publishedBy.set(target, done.filter(o => o.entry.status === 'publish').map(o => o.entry));
@@ -257,6 +289,46 @@ function assertDeclaredTargetsExist(app, packages) {
257
289
  }
258
290
  /** One `--json` row per package *and target*: a package shipping to two registries needs two, or a
259
291
  * consumer cannot tell which of them still has something to do. */
292
+ /**
293
+ * The files `publish.<target>.files` copies into `pkg` before that target publishes it - destination
294
+ * and source, both relative to the package. Throws naming the first source that does not exist, or
295
+ * a destination outside the package.
296
+ */
297
+ /* **Copied, never written from config** - the user's call: a file a platform needs (a `config.json`,
298
+ * an image, a font) is kept in the repository as the file it is, with a diff and a history, and the
299
+ * config only says where it goes. Generic over targets because it is the same need for each. */
300
+ function filesFor(pkg, target) {
301
+ const block = pkg.config.publish?.[target.name];
302
+ const files = block && typeof block === 'object' ? block.files : undefined;
303
+ if (!files || typeof files !== 'object')
304
+ return [];
305
+ return Object.entries(files).map(([to, from]) => {
306
+ const where = `publish.${target.name}.files["${to}"]`;
307
+ if (typeof from !== 'string' || !from)
308
+ throw new Error(`${where} must name a file or directory to copy`);
309
+ const dest = path.resolve(pkg.dirname, to);
310
+ const source = path.resolve(pkg.dirname, from);
311
+ const relative = path.relative(pkg.dirname, dest);
312
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
313
+ throw new Error(`${where} must be a path inside the package`);
314
+ }
315
+ if (!fs.existsSync(source))
316
+ throw new Error(`${where}: ${from} does not exist`);
317
+ return { source, dest };
318
+ });
319
+ }
320
+ /** Under a target's plan, what each package to publish will have copied in - so `--dry-run` says
321
+ * it. */
322
+ function printFiles(plan, target) {
323
+ for (const entry of plan) {
324
+ if (entry.status !== 'publish')
325
+ continue;
326
+ for (const file of filesFor(entry.package, target)) {
327
+ const rel = (p) => path.relative(entry.package.dirname, p);
328
+ console.log(colors.gray(` ${entry.package.name}: ${rel(file.dest)} <- ${rel(file.source)}`));
329
+ }
330
+ }
331
+ }
260
332
  function jsonPlan(plans) {
261
333
  return [...plans].flatMap(([target, plan]) => plan.map(entry => ({
262
334
  name: entry.package.name,
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.23.0';
1
+ export const version = '2.24.1';
@@ -1,4 +1,4 @@
1
- import type { RmanConfig } from '../../interfaces/rman-config.interface.js';
1
+ import type { ConfigValue, RmanConfig } from '../../interfaces/rman-config.interface.js';
2
2
  import type { PackageFilterOptions } from '../../utils/package-filter.js';
3
3
  import type { RmanApplication } from '../application.js';
4
4
  import type { Package } from '../classes/package.js';
@@ -131,6 +131,19 @@ export declare namespace PublishTarget {
131
131
  reason?: string;
132
132
  }
133
133
  }
134
+ /**
135
+ * `publish.<target>.files` - files copied into the package just before that target publishes it,
136
+ * and put back afterwards. Every target's block takes it; `publish` itself does the copying.
137
+ */
138
+ export interface PublishFilesKeys {
139
+ /**
140
+ * Destination -> source, both relative to the package: `{ "dist/config.json":
141
+ * "deploy/cloudflare/config.json" }`. A source may be a file of any kind or a directory. What a
142
+ * destination held is moved aside and put back once the target is done, so each target sees only
143
+ * its own files.
144
+ */
145
+ files?: ConfigValue<Record<string, string>>;
146
+ }
134
147
  /**
135
148
  * **A target's name, and deliberately not a union.**
136
149
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.23.0",
4
+ "version": "2.24.1",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -4,7 +4,7 @@ import fastGlob from 'fast-glob';
4
4
  import { Package } from '../core/classes/package.js';
5
5
  import { Repository } from '../core/classes/repository.js';
6
6
  import { Service } from '../core/classes/service.js';
7
- import { GitHelper } from '../utils/git.js';
7
+ import { dirtyReason, GitHelper } from '../utils/git.js';
8
8
  import { expandReleaseTag, isCalendarVersion, releaseTagPattern } from '../utils/release-version.js';
9
9
  import { ChangeHashService } from './change-hash.service.js';
10
10
  /**
@@ -49,14 +49,15 @@ export class GithubReleaseService extends Service {
49
49
  ];
50
50
  }
51
51
  const tag = releaseTagFor(root);
52
- if ((await git.listDirtyFiles()).length) {
52
+ const dirtyFiles = await git.listDirtyFiles();
53
+ if (dirtyFiles.length) {
53
54
  return [
54
55
  {
55
56
  ...base,
56
57
  tag,
57
58
  repository: repo,
58
59
  status: options.ignoreDirty ? 'skip' : 'error',
59
- reason: 'uncommitted local changes',
60
+ reason: dirtyReason(dirtyFiles, repository.dirname),
60
61
  },
61
62
  ];
62
63
  }
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { RmanApplication } from '../core/application.js';
3
3
  import { assertOneScheme, semverScheme, VersionScheme } from '../core/classes/version-scheme.js';
4
4
  import { skipReasonFor, targetsOf } from '../core/interfaces/publish-target.js';
5
- import { GitHelper } from '../utils/git.js';
5
+ import { dirtyReason, filesUnder, GitHelper } from '../utils/git.js';
6
6
  import { filterPackages } from '../utils/package-filter.js';
7
7
  import { findLastReleaseVersion, formatCalendarVersion, usesCalendarVersion } from '../utils/release-version.js';
8
8
  import { groupKeyOf, ROOT_GROUP_KEY } from '../utils/version-group.js';
@@ -98,7 +98,8 @@ export class VersionPlanService {
98
98
  const git = new GitHelper({ cwd: repository.dirname });
99
99
  const packages = filterPackages(repository.getPackages(), options);
100
100
  const dirtyFiles = await git.listDirtyFiles({ absolute: true });
101
- const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
101
+ const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
102
+ const dirtyReasonOf = (pkg) => dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname);
102
103
  const dirty = packages.filter(isDirty);
103
104
  const entries = new Map();
104
105
  /**
@@ -149,7 +150,7 @@ export class VersionPlanService {
149
150
  group: this.groupLabel(this.resolveGroupKey(pkg)),
150
151
  status: 'skip',
151
152
  from: pkg.version,
152
- reason: 'uncommitted local changes',
153
+ reason: dirtyReasonOf(pkg),
153
154
  });
154
155
  }
155
156
  const commitMessage = repository.rootPackage.config?.version?.commitMessage;
@@ -209,7 +210,7 @@ export class VersionPlanService {
209
210
  if (!entry || unversioned.has(pkg))
210
211
  continue;
211
212
  entry.status = 'error';
212
- entry.reason = entry.reason ? `uncommitted local changes (${entry.reason})` : 'uncommitted local changes';
213
+ entry.reason = entry.reason ? `${dirtyReasonOf(pkg)} (${entry.reason})` : dirtyReasonOf(pkg);
213
214
  }
214
215
  }
215
216
  return result;
package/utils/git.d.ts CHANGED
@@ -150,3 +150,13 @@ export declare namespace GitHelper {
150
150
  * show the total while the count is still zero. */
151
151
  type CommitProgress = (done: number, total: number) => void;
152
152
  }
153
+ /** What every "you have uncommitted changes" refusal starts with - compared against by callers, so
154
+ * it lives once. */
155
+ export declare const UNCOMMITTED = "uncommitted local changes";
156
+ /**
157
+ * `uncommitted local changes: a, b, c (+4 more)` - the refusal with the files that cause it,
158
+ * relative to `root`, at most three of them.
159
+ */
160
+ export declare function dirtyReason(files: readonly string[], root: string): string;
161
+ /** The files in `files` that sit inside `dir` - both absolute. */
162
+ export declare function filesUnder(files: readonly string[], dir: string): string[];
package/utils/git.js CHANGED
@@ -479,3 +479,24 @@ const execFileP = promisify(execFile);
479
479
  function execFileAsync(file, args, options) {
480
480
  return execFileP(file, args, { maxBuffer: GIT_MAX_BUFFER, ...options });
481
481
  }
482
+ /** What every "you have uncommitted changes" refusal starts with - compared against by callers, so
483
+ * it lives once. */
484
+ export const UNCOMMITTED = 'uncommitted local changes';
485
+ /**
486
+ * `uncommitted local changes: a, b, c (+4 more)` - the refusal with the files that cause it,
487
+ * relative to `root`, at most three of them.
488
+ */
489
+ /* **Named, because the bare sentence cost a release.** On `panates/syncbridge` a build rewrote a
490
+ * Dockerfile label, `rman github-release` answered only `uncommitted local changes`, and the file was
491
+ * found by reading every build script in the repository - the CI log had nothing else to say. */
492
+ export function dirtyReason(files, root) {
493
+ if (!files.length)
494
+ return UNCOMMITTED;
495
+ const shown = files.slice(0, 3).map(f => (path.isAbsolute(f) ? path.relative(root, f) : f));
496
+ const more = files.length > shown.length ? ` (+${files.length - shown.length} more)` : '';
497
+ return `${UNCOMMITTED}: ${shown.join(', ')}${more}`;
498
+ }
499
+ /** The files in `files` that sit inside `dir` - both absolute. */
500
+ export function filesUnder(files, dir) {
501
+ return files.filter(f => !path.relative(dir, f).startsWith('..'));
502
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Files copied into a package for one publish target, and put back afterwards - see
3
+ * `publish.<target>.files`.
4
+ *
5
+ * `stage` writes a journal, moves aside whatever each destination held, and copies the sources in;
6
+ * `restore` removes the copies and moves the originals back. A run that dies between the two leaves
7
+ * the journal behind, and `recover` - called before the next publish plans anything - finishes the
8
+ * restore, so one target's files never reach another target's publish.
9
+ */
10
+ export declare class PublishFiles {
11
+ protected readonly dir: string;
12
+ protected readonly journal: PublishFiles.Journal;
13
+ private _done;
14
+ private _unhook?;
15
+ protected constructor(dir: string, journal: PublishFiles.Journal);
16
+ /**
17
+ * Copies `files` in, after moving aside whatever their destinations held, and returns the handle
18
+ * that puts everything back. `label` names the run in the journal - `[docker] my-app`.
19
+ */
20
+ static stage(repositoryDir: string, label: string, files: readonly PublishFiles.File[]): PublishFiles;
21
+ /**
22
+ * Finishes the restore a previous run left unfinished, and returns the destinations it put back -
23
+ * empty when there was nothing to do.
24
+ */
25
+ static recover(repositoryDir: string): string[];
26
+ /** Where the journal and the moved-aside originals live for this repository. */
27
+ static stateDir(repositoryDir: string): string;
28
+ /** Removes the copies, moves the originals back, drops the directories staging created, and the
29
+ * journal last. Safe to call twice. */
30
+ restore(): void;
31
+ protected writeJournal(): void;
32
+ }
33
+ export declare namespace PublishFiles {
34
+ /** One copy: `source` into `dest`, both absolute. */
35
+ interface File {
36
+ source: string;
37
+ dest: string;
38
+ }
39
+ interface JournalEntry {
40
+ dest: string;
41
+ /** Where the destination's previous content was moved, when it had any. */
42
+ backup?: string;
43
+ /** Directories that did not exist and were created to hold `dest`, outermost first. */
44
+ createdDirs: string[];
45
+ }
46
+ interface Journal {
47
+ label: string;
48
+ entries: JournalEntry[];
49
+ }
50
+ }
@@ -0,0 +1,148 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { onExit } from 'signal-exit';
6
+ /**
7
+ * Files copied into a package for one publish target, and put back afterwards - see
8
+ * `publish.<target>.files`.
9
+ *
10
+ * `stage` writes a journal, moves aside whatever each destination held, and copies the sources in;
11
+ * `restore` removes the copies and moves the originals back. A run that dies between the two leaves
12
+ * the journal behind, and `recover` - called before the next publish plans anything - finishes the
13
+ * restore, so one target's files never reach another target's publish.
14
+ */
15
+ /* **Kept in `.git/rman/publish-files`**, the user's choice and for three reasons: it is inside the
16
+ * repository, so the next run finds it; git never commits it, so it needs no `.gitignore`; and it
17
+ * survives a reboot, which `os.tmpdir()` may not - and a reboot is the case the journal exists for.
18
+ * A directory that is not a git repository falls back to the temporary directory.
19
+ *
20
+ * **Moved aside, not copied.** A rename on one filesystem is instant whatever the size - an image,
21
+ * a whole directory - and the original is untouched until it is moved back. Across filesystems it
22
+ * copies and removes instead.
23
+ *
24
+ * **The journal is written before anything is touched**, listing every destination and whether it
25
+ * held something, so a run killed at any point leaves enough to undo exactly what it did.
26
+ *
27
+ * Restoring is synchronous, so it can run from `signal-exit`'s handler on Ctrl-C or a terminated
28
+ * CI job - the same hook `child-tracker` uses to kill children. */
29
+ export class PublishFiles {
30
+ dir;
31
+ journal;
32
+ _done = false;
33
+ _unhook;
34
+ constructor(dir, journal) {
35
+ this.dir = dir;
36
+ this.journal = journal;
37
+ }
38
+ /**
39
+ * Copies `files` in, after moving aside whatever their destinations held, and returns the handle
40
+ * that puts everything back. `label` names the run in the journal - `[docker] my-app`.
41
+ */
42
+ static stage(repositoryDir, label, files) {
43
+ const dir = PublishFiles.stateDir(repositoryDir);
44
+ fs.mkdirSync(path.join(dir, 'backup'), { recursive: true });
45
+ const entries = files.map((file, i) => {
46
+ const existed = lexists(file.dest);
47
+ return {
48
+ dest: file.dest,
49
+ backup: existed ? path.join(dir, 'backup', String(i)) : undefined,
50
+ createdDirs: missingParents(file.dest),
51
+ };
52
+ });
53
+ const staged = new this(dir, { label, entries });
54
+ staged.writeJournal();
55
+ staged._unhook = onExit(() => staged.restore());
56
+ for (const [i, entry] of entries.entries()) {
57
+ if (entry.backup)
58
+ move(entry.dest, entry.backup);
59
+ fs.mkdirSync(path.dirname(entry.dest), { recursive: true });
60
+ fs.cpSync(files[i].source, entry.dest, { recursive: true });
61
+ }
62
+ return staged;
63
+ }
64
+ /**
65
+ * Finishes the restore a previous run left unfinished, and returns the destinations it put back -
66
+ * empty when there was nothing to do.
67
+ */
68
+ static recover(repositoryDir) {
69
+ const dir = PublishFiles.stateDir(repositoryDir);
70
+ const file = path.join(dir, JOURNAL);
71
+ if (!fs.existsSync(file))
72
+ return [];
73
+ const journal = JSON.parse(fs.readFileSync(file, 'utf-8'));
74
+ new this(dir, journal).restore();
75
+ return journal.entries.map(e => e.dest);
76
+ }
77
+ /** Where the journal and the moved-aside originals live for this repository. */
78
+ static stateDir(repositoryDir) {
79
+ try {
80
+ const gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
81
+ cwd: repositoryDir,
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ })
84
+ .toString()
85
+ .trim();
86
+ return path.join(gitDir, 'rman', 'publish-files');
87
+ }
88
+ catch {
89
+ return path.join(os.tmpdir(), 'rman-publish-files', Buffer.from(repositoryDir).toString('base64url'));
90
+ }
91
+ }
92
+ /** Removes the copies, moves the originals back, drops the directories staging created, and the
93
+ * journal last. Safe to call twice. */
94
+ restore() {
95
+ if (this._done)
96
+ return;
97
+ this._done = true;
98
+ this._unhook?.();
99
+ for (const entry of [...this.journal.entries].reverse()) {
100
+ fs.rmSync(entry.dest, { recursive: true, force: true });
101
+ if (entry.backup && lexists(entry.backup)) {
102
+ fs.mkdirSync(path.dirname(entry.dest), { recursive: true });
103
+ move(entry.backup, entry.dest);
104
+ }
105
+ for (const created of [...entry.createdDirs].reverse()) {
106
+ if (fs.existsSync(created) && !fs.readdirSync(created).length)
107
+ fs.rmdirSync(created);
108
+ }
109
+ }
110
+ fs.rmSync(this.dir, { recursive: true, force: true });
111
+ }
112
+ writeJournal() {
113
+ fs.writeFileSync(path.join(this.dir, JOURNAL), JSON.stringify(this.journal, undefined, 2));
114
+ }
115
+ }
116
+ const JOURNAL = 'journal.json';
117
+ /** Whether anything is at `p`, a broken symbolic link included. */
118
+ function lexists(p) {
119
+ try {
120
+ fs.lstatSync(p);
121
+ return true;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ /** The directories above `file` that do not exist yet, outermost first. */
128
+ function missingParents(file) {
129
+ const missing = [];
130
+ let dir = path.dirname(file);
131
+ while (!fs.existsSync(dir) && dir !== path.dirname(dir)) {
132
+ missing.unshift(dir);
133
+ dir = path.dirname(dir);
134
+ }
135
+ return missing;
136
+ }
137
+ /** `rename`, or copy and remove where the two paths are on different filesystems. */
138
+ function move(from, to) {
139
+ try {
140
+ fs.renameSync(from, to);
141
+ }
142
+ catch (e) {
143
+ if (e?.code !== 'EXDEV')
144
+ throw e;
145
+ fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
146
+ fs.rmSync(from, { recursive: true, force: true });
147
+ }
148
+ }