rman 2.22.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/builtins/platforms/node/node-config.interface.d.ts +2 -1
- package/builtins/platforms/node/services/publish.service.js +3 -3
- package/builtins/publish-targets/cloudflare/cloudflare-publish.service.d.ts +107 -0
- package/builtins/publish-targets/cloudflare/cloudflare-publish.service.js +243 -0
- package/builtins/publish-targets/cloudflare/cloudflare.target.d.ts +45 -0
- package/builtins/publish-targets/cloudflare/cloudflare.target.js +23 -0
- package/builtins/publish-targets/cloudflare/index.d.ts +2 -0
- package/builtins/publish-targets/cloudflare/index.js +2 -0
- package/builtins/publish-targets/docker/docker-publish.service.js +56 -5
- package/builtins/publish-targets/docker/docker.target.d.ts +22 -2
- package/commands/publish.command.d.ts +4 -0
- package/commands/publish.command.js +75 -3
- package/constants.js +1 -1
- package/core/application.js +3 -0
- package/core/interfaces/publish-target.d.ts +14 -1
- package/package.json +1 -1
- package/services/github-release.service.js +4 -3
- package/services/version-plan.service.js +5 -4
- package/utils/git.d.ts +10 -0
- package/utils/git.js +21 -0
- package/utils/publish-files.d.ts +50 -0
- package/utils/publish-files.js +148 -0
|
@@ -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
|
|
@@ -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
|
|
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:
|
|
253
|
+
reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
|
|
254
254
|
});
|
|
255
255
|
}
|
|
256
256
|
else {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Package } from '../../../core/classes/package.js';
|
|
2
|
+
import { Service } from '../../../core/classes/service.js';
|
|
3
|
+
import { type PublishTarget } from '../../../core/interfaces/publish-target.js';
|
|
4
|
+
import { type PackageFilterOptions } from '../../../utils/package-filter.js';
|
|
5
|
+
/** The name this target answers to in `publish.target` and `--target`. */
|
|
6
|
+
export declare const CLOUDFLARE_TARGET = "cloudflare";
|
|
7
|
+
/**
|
|
8
|
+
* Deploys packages to Cloudflare Pages or Workers through `wrangler`, once per version.
|
|
9
|
+
*
|
|
10
|
+
* **Question B, like every target**: is *this version* already deployed? Each deploy is labelled
|
|
11
|
+
* with the version - a Pages deployment's commit message is `<name>@<version>`, a Worker version's
|
|
12
|
+
* tag is `v<version>` - and the plan reads those labels back, so a release run twice deploys once.
|
|
13
|
+
*/
|
|
14
|
+
export declare class CloudflarePublishService extends Service {
|
|
15
|
+
/** The wrangler configuration a Worker package is looked for in, in order. */
|
|
16
|
+
protected readonly workerConfigFiles: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* What `publish --target cloudflare` would do. Only packages naming `"cloudflare"` in their own
|
|
19
|
+
* `publish.target` are candidates. A candidate whose config is incomplete, whose working tree is
|
|
20
|
+
* dirty (unless `ignoreDirty`), or that runs without `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID`
|
|
21
|
+
* is an `'error'`; otherwise it is `'up-to-date'` when its version is already deployed and
|
|
22
|
+
* `'publish'` when it is not.
|
|
23
|
+
*/
|
|
24
|
+
getPlan(options?: CloudflarePublishService.Options, deps?: CloudflarePublishService.Deps): Promise<CloudflarePublishService.Entry[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Deploys every `'publish'` entry with `wrangler`, one package after another. A failure is that
|
|
27
|
+
* package's alone and is reported on its entry.
|
|
28
|
+
*/
|
|
29
|
+
applyPlan(plan: CloudflarePublishService.Entry[]): Promise<CloudflarePublishService.Entry[]>;
|
|
30
|
+
/** Where and how `pkg` deploys, from its `publish.cloudflare` - or a thrown reason it cannot. */
|
|
31
|
+
protected resolveDeploy(pkg: Package): CloudflarePublishService.Deploy;
|
|
32
|
+
/** The API token and account id wrangler reads, or `undefined` when either is missing. */
|
|
33
|
+
protected credentials(): {
|
|
34
|
+
token: string;
|
|
35
|
+
accountId: string;
|
|
36
|
+
} | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Whether this version is already deployed. A check that cannot be answered - the network, an
|
|
39
|
+
* expired token - reads as "not deployed", the catch-everything shape every target's registry
|
|
40
|
+
* check has: deploying a version again is harmless, failing the run over a question is not.
|
|
41
|
+
*/
|
|
42
|
+
protected isDeployed(deploy: CloudflarePublishService.Deploy): Promise<boolean>;
|
|
43
|
+
/** A Pages deployment of the project whose commit message is this version's marker. */
|
|
44
|
+
protected pagesHas(deploy: CloudflarePublishService.PagesDeploy): Promise<boolean>;
|
|
45
|
+
/** A version of the Worker tagged with this version - among the ten most recent wrangler lists. */
|
|
46
|
+
protected workerHas(deploy: CloudflarePublishService.WorkersDeploy): Promise<boolean>;
|
|
47
|
+
/** Runs the deploy, its output shown on stderr as it comes and kept for the reason if it fails. */
|
|
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[];
|
|
52
|
+
/**
|
|
53
|
+
* `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
|
|
54
|
+
* and through `npx` when it is not - so a package that never added it as a dependency still
|
|
55
|
+
* deploys.
|
|
56
|
+
*/
|
|
57
|
+
protected wrangler(pkg: Package, argv: string[], onLine: (line: string, stream: 'stdout' | 'stderr') => void): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
export declare namespace CloudflarePublishService {
|
|
60
|
+
/** Injectable "is this version deployed" check, so a spec needs neither network nor wrangler. */
|
|
61
|
+
export interface Deps {
|
|
62
|
+
isDeployed?: (deploy: Deploy) => Promise<boolean>;
|
|
63
|
+
}
|
|
64
|
+
export interface Options extends PackageFilterOptions {
|
|
65
|
+
/** A package with uncommitted local changes is excluded (`'skip'`) instead of aborting the
|
|
66
|
+
* plan (`'error'`) - the same option every target takes. */
|
|
67
|
+
ignoreDirty?: boolean;
|
|
68
|
+
}
|
|
69
|
+
interface DeployBase {
|
|
70
|
+
pkg: Package;
|
|
71
|
+
/** `<name>@<version>` - the Pages commit message and the Worker version's message, and what
|
|
72
|
+
* the plan looks for on a Pages project. */
|
|
73
|
+
marker: string;
|
|
74
|
+
/** What the plan prints beside the package. */
|
|
75
|
+
label: string;
|
|
76
|
+
}
|
|
77
|
+
export interface PagesDeploy extends DeployBase {
|
|
78
|
+
kind: 'pages';
|
|
79
|
+
project: string;
|
|
80
|
+
branch: string;
|
|
81
|
+
/** Absolute. */
|
|
82
|
+
directory: string;
|
|
83
|
+
}
|
|
84
|
+
export interface WorkersDeploy extends DeployBase {
|
|
85
|
+
kind: 'workers';
|
|
86
|
+
/** `v<version>` - the Worker version's tag, which the plan looks for. */
|
|
87
|
+
tag: string;
|
|
88
|
+
/** Absolute path of the wrangler configuration. */
|
|
89
|
+
config: string;
|
|
90
|
+
/** The wrangler environment, when one is named. */
|
|
91
|
+
env?: string;
|
|
92
|
+
/** `--var` entries. */
|
|
93
|
+
variables: Record<string, string>;
|
|
94
|
+
}
|
|
95
|
+
export type Deploy = PagesDeploy | WorkersDeploy;
|
|
96
|
+
/** One package's outcome - a `PublishTarget.Entry` carrying where it deploys. */
|
|
97
|
+
export interface Entry extends PublishTarget.Entry {
|
|
98
|
+
/** Unset only on an `'error'` entry whose config could not be read. */
|
|
99
|
+
deploy?: Deploy;
|
|
100
|
+
}
|
|
101
|
+
export {};
|
|
102
|
+
}
|
|
103
|
+
declare module '../../../core/classes/service.js' {
|
|
104
|
+
interface ServiceMap {
|
|
105
|
+
cloudflarePublish: CloudflarePublishService;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Service } from '../../../core/classes/service.js';
|
|
4
|
+
import { targetsOf } from '../../../core/interfaces/publish-target.js';
|
|
5
|
+
import { dirtyReason, filesUnder, GitHelper } from '../../../utils/git.js';
|
|
6
|
+
import { filterPackages } from '../../../utils/package-filter.js';
|
|
7
|
+
import { runBin } from '../../../utils/run-bin.js';
|
|
8
|
+
/** The name this target answers to in `publish.target` and `--target`. */
|
|
9
|
+
export const CLOUDFLARE_TARGET = 'cloudflare';
|
|
10
|
+
/**
|
|
11
|
+
* Deploys packages to Cloudflare Pages or Workers through `wrangler`, once per version.
|
|
12
|
+
*
|
|
13
|
+
* **Question B, like every target**: is *this version* already deployed? Each deploy is labelled
|
|
14
|
+
* with the version - a Pages deployment's commit message is `<name>@<version>`, a Worker version's
|
|
15
|
+
* tag is `v<version>` - and the plan reads those labels back, so a release run twice deploys once.
|
|
16
|
+
*/
|
|
17
|
+
/* **Pages is asked through Cloudflare's API, Workers through wrangler**, and the asymmetry is
|
|
18
|
+
* wrangler's. `wrangler versions list --json` returns a version's annotations, `workers/tag`
|
|
19
|
+
* among them; `wrangler pages deployment list --json` returns id, branch, a short commit hash, url
|
|
20
|
+
* and status, and drops the commit message - measured in wrangler 4.81's own source. The API has
|
|
21
|
+
* it (`deployment_trigger.metadata.commit_message`), with the same token wrangler uses. */
|
|
22
|
+
export class CloudflarePublishService extends Service {
|
|
23
|
+
/** The wrangler configuration a Worker package is looked for in, in order. */
|
|
24
|
+
workerConfigFiles = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'];
|
|
25
|
+
/**
|
|
26
|
+
* What `publish --target cloudflare` would do. Only packages naming `"cloudflare"` in their own
|
|
27
|
+
* `publish.target` are candidates. A candidate whose config is incomplete, whose working tree is
|
|
28
|
+
* dirty (unless `ignoreDirty`), or that runs without `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID`
|
|
29
|
+
* is an `'error'`; otherwise it is `'up-to-date'` when its version is already deployed and
|
|
30
|
+
* `'publish'` when it is not.
|
|
31
|
+
*/
|
|
32
|
+
async getPlan(options = {}, deps = {}) {
|
|
33
|
+
const repository = this.repository;
|
|
34
|
+
const git = new GitHelper({ cwd: repository.dirname });
|
|
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
|
+
const dirtyFiles = await git.listDirtyFiles({ absolute: true });
|
|
37
|
+
const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
|
|
38
|
+
const credentials = this.credentials();
|
|
39
|
+
const isDeployed = deps.isDeployed ?? ((deploy) => this.isDeployed(deploy));
|
|
40
|
+
return Promise.all(packages.map(async (pkg) => {
|
|
41
|
+
const base = { package: pkg, version: pkg.version };
|
|
42
|
+
let deploy;
|
|
43
|
+
try {
|
|
44
|
+
deploy = this.resolveDeploy(pkg);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
return { ...base, status: 'error', reason: e.message };
|
|
48
|
+
}
|
|
49
|
+
const located = { ...base, deploy, detail: deploy.label };
|
|
50
|
+
if (isDirty(pkg)) {
|
|
51
|
+
return {
|
|
52
|
+
...located,
|
|
53
|
+
status: options.ignoreDirty ? 'skip' : 'error',
|
|
54
|
+
reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (!credentials) {
|
|
58
|
+
return {
|
|
59
|
+
...located,
|
|
60
|
+
status: 'error',
|
|
61
|
+
reason: 'CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set to check and deploy',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const deployed = await isDeployed(deploy);
|
|
65
|
+
return deployed
|
|
66
|
+
? { ...located, status: 'up-to-date', reason: `${deploy.marker} is already deployed` }
|
|
67
|
+
: { ...located, status: 'publish', reason: 'not deployed yet' };
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Deploys every `'publish'` entry with `wrangler`, one package after another. A failure is that
|
|
72
|
+
* package's alone and is reported on its entry.
|
|
73
|
+
*/
|
|
74
|
+
async applyPlan(plan) {
|
|
75
|
+
const result = [];
|
|
76
|
+
for (const entry of plan) {
|
|
77
|
+
if (entry.status !== 'publish' || !entry.deploy) {
|
|
78
|
+
result.push(entry);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await this.deploy(entry.deploy);
|
|
83
|
+
result.push(entry);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
result.push({ ...entry, status: 'error', reason: e.message });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
/** Where and how `pkg` deploys, from its `publish.cloudflare` - or a thrown reason it cannot. */
|
|
92
|
+
resolveDeploy(pkg) {
|
|
93
|
+
const cf = pkg.config.publish?.cloudflare;
|
|
94
|
+
if (!cf)
|
|
95
|
+
throw new Error('"cloudflare" is a publish target but "publish.cloudflare" is not configured');
|
|
96
|
+
const marker = `${pkg.name}@${pkg.version}`;
|
|
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
|
+
}
|
|
105
|
+
const project = cf.project;
|
|
106
|
+
if (!project)
|
|
107
|
+
throw new Error('"publish.cloudflare.project" is required for kind "pages"');
|
|
108
|
+
const branch = cf.branch || 'main';
|
|
109
|
+
const directory = path.resolve(pkg.dirname, cf.directory || 'dist');
|
|
110
|
+
return { kind: 'pages', pkg, marker, project, branch, directory, label: `pages ${project} (${branch})` };
|
|
111
|
+
}
|
|
112
|
+
if (cf.kind === 'workers') {
|
|
113
|
+
const declared = cf.config;
|
|
114
|
+
const config = declared
|
|
115
|
+
? path.resolve(pkg.dirname, declared)
|
|
116
|
+
: this.workerConfigFiles.map(f => path.join(pkg.dirname, f)).find(f => fs.existsSync(f));
|
|
117
|
+
if (!config || !fs.existsSync(config)) {
|
|
118
|
+
throw new Error(declared
|
|
119
|
+
? `"publish.cloudflare.config" names ${declared}, which does not exist`
|
|
120
|
+
: `kind "workers" needs a wrangler configuration - none of ${this.workerConfigFiles.join(', ')} is in the package`);
|
|
121
|
+
}
|
|
122
|
+
const env = cf.env || undefined;
|
|
123
|
+
return {
|
|
124
|
+
kind: 'workers',
|
|
125
|
+
pkg,
|
|
126
|
+
marker,
|
|
127
|
+
tag: `v${pkg.version}`,
|
|
128
|
+
config,
|
|
129
|
+
env,
|
|
130
|
+
variables: cf.variables ?? {},
|
|
131
|
+
label: `workers ${path.relative(pkg.dirname, config)}${env ? ` (${env})` : ''}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`"publish.cloudflare.kind" must be "pages" or "workers", not ${JSON.stringify(cf.kind)}`);
|
|
135
|
+
}
|
|
136
|
+
/** The API token and account id wrangler reads, or `undefined` when either is missing. */
|
|
137
|
+
credentials() {
|
|
138
|
+
const token = process.env.CLOUDFLARE_API_TOKEN;
|
|
139
|
+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
140
|
+
return token && accountId ? { token, accountId } : undefined;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether this version is already deployed. A check that cannot be answered - the network, an
|
|
144
|
+
* expired token - reads as "not deployed", the catch-everything shape every target's registry
|
|
145
|
+
* check has: deploying a version again is harmless, failing the run over a question is not.
|
|
146
|
+
*/
|
|
147
|
+
async isDeployed(deploy) {
|
|
148
|
+
try {
|
|
149
|
+
if (deploy.kind === 'pages')
|
|
150
|
+
return await this.pagesHas(deploy);
|
|
151
|
+
return await this.workerHas(deploy);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A Pages deployment of the project whose commit message is this version's marker. */
|
|
158
|
+
async pagesHas(deploy) {
|
|
159
|
+
const { token, accountId } = this.credentials();
|
|
160
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/` +
|
|
161
|
+
`${encodeURIComponent(deploy.project)}/deployments`;
|
|
162
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
163
|
+
if (!res.ok)
|
|
164
|
+
return false;
|
|
165
|
+
const body = (await res.json());
|
|
166
|
+
return (body.result ?? []).some(d => d.deployment_trigger?.metadata?.commit_message === deploy.marker);
|
|
167
|
+
}
|
|
168
|
+
/** A version of the Worker tagged with this version - among the ten most recent wrangler lists. */
|
|
169
|
+
async workerHas(deploy) {
|
|
170
|
+
const lines = [];
|
|
171
|
+
const argv = ['versions', 'list', '--json', '--config', deploy.config, ...this.envArgs(deploy)];
|
|
172
|
+
await this.wrangler(deploy.pkg, argv, (line, stream) => {
|
|
173
|
+
if (stream === 'stdout')
|
|
174
|
+
lines.push(line);
|
|
175
|
+
});
|
|
176
|
+
const versions = JSON.parse(lines.join('\n'));
|
|
177
|
+
return versions.some(v => v.annotations?.['workers/tag'] === deploy.tag);
|
|
178
|
+
}
|
|
179
|
+
/** Runs the deploy, its output shown on stderr as it comes and kept for the reason if it fails. */
|
|
180
|
+
async deploy(deploy) {
|
|
181
|
+
if (deploy.kind === 'pages' && !fs.existsSync(deploy.directory)) {
|
|
182
|
+
throw new Error(`${path.relative(deploy.pkg.dirname, deploy.directory)} does not exist - build the package first`);
|
|
183
|
+
}
|
|
184
|
+
const argv = deploy.kind === 'pages'
|
|
185
|
+
? [
|
|
186
|
+
'pages',
|
|
187
|
+
'deploy',
|
|
188
|
+
deploy.directory,
|
|
189
|
+
'--project-name',
|
|
190
|
+
deploy.project,
|
|
191
|
+
'--branch',
|
|
192
|
+
deploy.branch,
|
|
193
|
+
'--commit-message',
|
|
194
|
+
deploy.marker,
|
|
195
|
+
'--commit-dirty=true',
|
|
196
|
+
]
|
|
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
|
+
];
|
|
208
|
+
const lines = [];
|
|
209
|
+
try {
|
|
210
|
+
await this.wrangler(deploy.pkg, argv, line => {
|
|
211
|
+
lines.push(line);
|
|
212
|
+
process.stderr.write(line + '\n');
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
const tail = lines.filter(l => /error|✘/i.test(l)).slice(-4);
|
|
217
|
+
throw new Error(e.message + (tail.length ? ':\n' + tail.map(l => ` ${l.trim()}`).join('\n') : ''), {
|
|
218
|
+
cause: e,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
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
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
|
|
229
|
+
* and through `npx` when it is not - so a package that never added it as a dependency still
|
|
230
|
+
* deploys.
|
|
231
|
+
*/
|
|
232
|
+
async wrangler(pkg, argv, onLine) {
|
|
233
|
+
const options = { cwd: pkg.dirname, app: this.app, logLevel: 'silent', onLine };
|
|
234
|
+
try {
|
|
235
|
+
await runBin('wrangler', argv, options);
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
if (!/was not found/.test(e?.message ?? ''))
|
|
239
|
+
throw e;
|
|
240
|
+
await runBin('npx', ['--yes', 'wrangler', ...argv], options);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PublishFilesKeys, PublishTarget } from '../../../core/interfaces/publish-target.js';
|
|
2
|
+
import type { ConfigValue, ScopedVars } from '../../../interfaces/rman-config.interface.js';
|
|
3
|
+
/**
|
|
4
|
+
* **`publish.cloudflare.*`** - this target's own config block, declared beside it and reaching
|
|
5
|
+
* `RmanConfig` through the `PublishTargetConfigs` slot `publish.command.ts` exports, as
|
|
6
|
+
* `publish.docker.*` does.
|
|
7
|
+
*
|
|
8
|
+
* Required once `"cloudflare"` is one of a package's `publish.target`s.
|
|
9
|
+
*/
|
|
10
|
+
export interface CloudflarePublishOptions extends CloudflarePublishOptionsKeys, ScopedVars {
|
|
11
|
+
}
|
|
12
|
+
export interface CloudflarePublishOptionsKeys extends PublishFilesKeys {
|
|
13
|
+
/**
|
|
14
|
+
* What the package deploys as: `"pages"` - a directory of static files uploaded to a Pages
|
|
15
|
+
* project (`wrangler pages deploy`) - or `"workers"` - a Worker, static assets included, as its
|
|
16
|
+
* own wrangler configuration describes it (`wrangler deploy`).
|
|
17
|
+
*/
|
|
18
|
+
kind: ConfigValue<'pages' | 'workers'>;
|
|
19
|
+
/** Pages: the project to deploy to. Required for `"pages"`. */
|
|
20
|
+
project?: ConfigValue<string>;
|
|
21
|
+
/** Pages: the branch the deployment is made for - the project's production branch puts it live.
|
|
22
|
+
* Default `"main"`. */
|
|
23
|
+
branch?: ConfigValue<string>;
|
|
24
|
+
/** Pages: the directory to upload, relative to the package. Default `"dist"`. */
|
|
25
|
+
directory?: ConfigValue<string>;
|
|
26
|
+
/** Workers: the wrangler configuration file, relative to the package. Default: the first of
|
|
27
|
+
* `wrangler.jsonc`, `wrangler.json`, `wrangler.toml` in the package. */
|
|
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>>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* **Cloudflare, as a publish target** - a static site on Pages, or a Worker with its assets.
|
|
39
|
+
*
|
|
40
|
+
* Opt-in like `docker`, so no `claims`: a package deploys here only by naming `"cloudflare"` in
|
|
41
|
+
* its own `publish.target`. What that buys over a workflow running `wrangler` on its own is what
|
|
42
|
+
* every target gets from `publish`: `publish.skip`, `--dry-run` and `--json`, and a deploy that
|
|
43
|
+
* happens once per version rather than once per merge.
|
|
44
|
+
*/
|
|
45
|
+
export declare const cloudflarePublishTarget: PublishTarget;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CLOUDFLARE_TARGET } from './cloudflare-publish.service.js';
|
|
2
|
+
/**
|
|
3
|
+
* **Cloudflare, as a publish target** - a static site on Pages, or a Worker with its assets.
|
|
4
|
+
*
|
|
5
|
+
* Opt-in like `docker`, so no `claims`: a package deploys here only by naming `"cloudflare"` in
|
|
6
|
+
* its own `publish.target`. What that buys over a workflow running `wrangler` on its own is what
|
|
7
|
+
* every target gets from `publish`: `publish.skip`, `--dry-run` and `--json`, and a deploy that
|
|
8
|
+
* happens once per version rather than once per merge.
|
|
9
|
+
*/
|
|
10
|
+
/* **Last, with `docker`.** A site's build installs from the registries the other targets publish
|
|
11
|
+
* to, so it waits for what they published - the same reason `docker` runs last. */
|
|
12
|
+
export const cloudflarePublishTarget = {
|
|
13
|
+
name: CLOUDFLARE_TARGET,
|
|
14
|
+
describe: 'Deploy to Cloudflare Pages or Workers (wrangler)',
|
|
15
|
+
publishesLast: true,
|
|
16
|
+
getPlan(ctx) {
|
|
17
|
+
return ctx.app.getService('cloudflarePublish').getPlan(ctx.options);
|
|
18
|
+
},
|
|
19
|
+
applyPlan(ctx, plan) {
|
|
20
|
+
/** The entries are this target's own - `getPlan` produced them. */
|
|
21
|
+
return ctx.app.getService('cloudflarePublish').applyPlan(plan);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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,3 +1,4 @@
|
|
|
1
|
+
import type { CloudflarePublishOptions } from '../builtins/publish-targets/cloudflare/cloudflare.target.js';
|
|
1
2
|
import type { DockerPublishOptions } from '../builtins/publish-targets/docker/docker.target.js';
|
|
2
3
|
import type { RmanApplication } from '../core/application.js';
|
|
3
4
|
import { type RmanConfig } from '../interfaces/rman-config.interface.js';
|
|
@@ -110,6 +111,9 @@ export interface PublishTargetConfigs {
|
|
|
110
111
|
/** Required once `"docker"` is one of a package's `publish.target`s - `publish --target docker`
|
|
111
112
|
* errors clearly on a package that opts in here but leaves this out. */
|
|
112
113
|
docker?: DockerPublishOptions;
|
|
114
|
+
/** Required once `"cloudflare"` is one of a package's `publish.target`s - `kind` says Pages or
|
|
115
|
+
* Workers. */
|
|
116
|
+
cloudflare?: CloudflarePublishOptions;
|
|
113
117
|
}
|
|
114
118
|
type Args = RmanConfig.ArgsOf<typeof config, typeof COMMAND>;
|
|
115
119
|
/**
|
|
@@ -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
|
|
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
|
|
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.
|
|
1
|
+
export const version = '2.24.0';
|
package/core/application.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CloudflarePublishService, cloudflarePublishTarget } from '../builtins/publish-targets/cloudflare/index.js';
|
|
1
2
|
import { DockerPublishService, dockerPublishTarget } from '../builtins/publish-targets/docker/index.js';
|
|
2
3
|
import { ChangelogService } from '../services/changelog.service.js';
|
|
3
4
|
import { ConfigService } from '../services/config-service.js';
|
|
@@ -93,6 +94,7 @@ export class RmanApplication {
|
|
|
93
94
|
// Register core services.
|
|
94
95
|
this.setService('config', a => new ConfigService(a));
|
|
95
96
|
this.setService('changelog', a => new ChangelogService(a));
|
|
97
|
+
this.setService('cloudflarePublish', a => new CloudflarePublishService(a));
|
|
96
98
|
this.setService('dockerPublish', a => new DockerPublishService(a));
|
|
97
99
|
this.setService('githubRelease', a => new GithubReleaseService(a));
|
|
98
100
|
this.setService('exec', a => new ExecService(a));
|
|
@@ -102,6 +104,7 @@ export class RmanApplication {
|
|
|
102
104
|
this.setService('version', a => new VersionService(a));
|
|
103
105
|
// Register core targets.
|
|
104
106
|
this.publishTargets.add(dockerPublishTarget);
|
|
107
|
+
this.publishTargets.add(cloudflarePublishTarget);
|
|
105
108
|
}
|
|
106
109
|
/** Which platform claims a directory - the first whose manifest provider recognizes it, because
|
|
107
110
|
* before a package is read there is nothing else to go on. `basePlatform` when none does, so the
|
|
@@ -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
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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 ?
|
|
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
|
+
}
|