rman 2.21.1 → 2.23.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.
@@ -0,0 +1,100 @@
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
+ /**
50
+ * `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
51
+ * and through `npx` when it is not - so a package that never added it as a dependency still
52
+ * deploys.
53
+ */
54
+ protected wrangler(pkg: Package, argv: string[], onLine: (line: string, stream: 'stdout' | 'stderr') => void): Promise<void>;
55
+ }
56
+ export declare namespace CloudflarePublishService {
57
+ /** Injectable "is this version deployed" check, so a spec needs neither network nor wrangler. */
58
+ export interface Deps {
59
+ isDeployed?: (deploy: Deploy) => Promise<boolean>;
60
+ }
61
+ export interface Options extends PackageFilterOptions {
62
+ /** A package with uncommitted local changes is excluded (`'skip'`) instead of aborting the
63
+ * plan (`'error'`) - the same option every target takes. */
64
+ ignoreDirty?: boolean;
65
+ }
66
+ interface DeployBase {
67
+ pkg: Package;
68
+ /** `<name>@<version>` - the Pages commit message and the Worker version's message, and what
69
+ * the plan looks for on a Pages project. */
70
+ marker: string;
71
+ /** What the plan prints beside the package. */
72
+ label: string;
73
+ }
74
+ export interface PagesDeploy extends DeployBase {
75
+ kind: 'pages';
76
+ project: string;
77
+ branch: string;
78
+ /** Absolute. */
79
+ directory: string;
80
+ }
81
+ export interface WorkersDeploy extends DeployBase {
82
+ kind: 'workers';
83
+ /** `v<version>` - the Worker version's tag, which the plan looks for. */
84
+ tag: string;
85
+ /** Absolute path of the wrangler configuration. */
86
+ config: string;
87
+ }
88
+ export type Deploy = PagesDeploy | WorkersDeploy;
89
+ /** One package's outcome - a `PublishTarget.Entry` carrying where it deploys. */
90
+ export interface Entry extends PublishTarget.Entry {
91
+ /** Unset only on an `'error'` entry whose config could not be read. */
92
+ deploy?: Deploy;
93
+ }
94
+ export {};
95
+ }
96
+ declare module '../../../core/classes/service.js' {
97
+ interface ServiceMap {
98
+ cloudflarePublish: CloudflarePublishService;
99
+ }
100
+ }
@@ -0,0 +1,213 @@
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 { 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) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
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 { ...located, status: options.ignoreDirty ? 'skip' : 'error', reason: 'uncommitted local changes' };
52
+ }
53
+ if (!credentials) {
54
+ return {
55
+ ...located,
56
+ status: 'error',
57
+ reason: 'CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set to check and deploy',
58
+ };
59
+ }
60
+ const deployed = await isDeployed(deploy);
61
+ return deployed
62
+ ? { ...located, status: 'up-to-date', reason: `${deploy.marker} is already deployed` }
63
+ : { ...located, status: 'publish', reason: 'not deployed yet' };
64
+ }));
65
+ }
66
+ /**
67
+ * Deploys every `'publish'` entry with `wrangler`, one package after another. A failure is that
68
+ * package's alone and is reported on its entry.
69
+ */
70
+ async applyPlan(plan) {
71
+ const result = [];
72
+ for (const entry of plan) {
73
+ if (entry.status !== 'publish' || !entry.deploy) {
74
+ result.push(entry);
75
+ continue;
76
+ }
77
+ try {
78
+ await this.deploy(entry.deploy);
79
+ result.push(entry);
80
+ }
81
+ catch (e) {
82
+ result.push({ ...entry, status: 'error', reason: e.message });
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+ /** Where and how `pkg` deploys, from its `publish.cloudflare` - or a thrown reason it cannot. */
88
+ resolveDeploy(pkg) {
89
+ const cf = pkg.config.publish?.cloudflare;
90
+ if (!cf)
91
+ throw new Error('"cloudflare" is a publish target but "publish.cloudflare" is not configured');
92
+ const marker = `${pkg.name}@${pkg.version}`;
93
+ if (cf.kind === 'pages') {
94
+ const project = cf.project;
95
+ if (!project)
96
+ throw new Error('"publish.cloudflare.project" is required for kind "pages"');
97
+ const branch = cf.branch || 'main';
98
+ const directory = path.resolve(pkg.dirname, cf.directory || 'dist');
99
+ return { kind: 'pages', pkg, marker, project, branch, directory, label: `pages ${project} (${branch})` };
100
+ }
101
+ if (cf.kind === 'workers') {
102
+ const declared = cf.config;
103
+ const config = declared
104
+ ? path.resolve(pkg.dirname, declared)
105
+ : this.workerConfigFiles.map(f => path.join(pkg.dirname, f)).find(f => fs.existsSync(f));
106
+ if (!config || !fs.existsSync(config)) {
107
+ throw new Error(declared
108
+ ? `"publish.cloudflare.config" names ${declared}, which does not exist`
109
+ : `kind "workers" needs a wrangler configuration - none of ${this.workerConfigFiles.join(', ')} is in the package`);
110
+ }
111
+ return {
112
+ kind: 'workers',
113
+ pkg,
114
+ marker,
115
+ tag: `v${pkg.version}`,
116
+ config,
117
+ label: `workers ${path.relative(pkg.dirname, config)}`,
118
+ };
119
+ }
120
+ throw new Error(`"publish.cloudflare.kind" must be "pages" or "workers", not ${JSON.stringify(cf.kind)}`);
121
+ }
122
+ /** The API token and account id wrangler reads, or `undefined` when either is missing. */
123
+ credentials() {
124
+ const token = process.env.CLOUDFLARE_API_TOKEN;
125
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
126
+ return token && accountId ? { token, accountId } : undefined;
127
+ }
128
+ /**
129
+ * Whether this version is already deployed. A check that cannot be answered - the network, an
130
+ * expired token - reads as "not deployed", the catch-everything shape every target's registry
131
+ * check has: deploying a version again is harmless, failing the run over a question is not.
132
+ */
133
+ async isDeployed(deploy) {
134
+ try {
135
+ if (deploy.kind === 'pages')
136
+ return await this.pagesHas(deploy);
137
+ return await this.workerHas(deploy);
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
143
+ /** A Pages deployment of the project whose commit message is this version's marker. */
144
+ async pagesHas(deploy) {
145
+ const { token, accountId } = this.credentials();
146
+ const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/` +
147
+ `${encodeURIComponent(deploy.project)}/deployments`;
148
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
149
+ if (!res.ok)
150
+ return false;
151
+ const body = (await res.json());
152
+ return (body.result ?? []).some(d => d.deployment_trigger?.metadata?.commit_message === deploy.marker);
153
+ }
154
+ /** A version of the Worker tagged with this version - among the ten most recent wrangler lists. */
155
+ async workerHas(deploy) {
156
+ const lines = [];
157
+ await this.wrangler(deploy.pkg, ['versions', 'list', '--json', '--config', deploy.config], (line, stream) => {
158
+ if (stream === 'stdout')
159
+ lines.push(line);
160
+ });
161
+ const versions = JSON.parse(lines.join('\n'));
162
+ return versions.some(v => v.annotations?.['workers/tag'] === deploy.tag);
163
+ }
164
+ /** Runs the deploy, its output shown on stderr as it comes and kept for the reason if it fails. */
165
+ async deploy(deploy) {
166
+ if (deploy.kind === 'pages' && !fs.existsSync(deploy.directory)) {
167
+ throw new Error(`${path.relative(deploy.pkg.dirname, deploy.directory)} does not exist - build the package first`);
168
+ }
169
+ const argv = deploy.kind === 'pages'
170
+ ? [
171
+ 'pages',
172
+ 'deploy',
173
+ deploy.directory,
174
+ '--project-name',
175
+ deploy.project,
176
+ '--branch',
177
+ deploy.branch,
178
+ '--commit-message',
179
+ deploy.marker,
180
+ '--commit-dirty=true',
181
+ ]
182
+ : ['deploy', '--config', deploy.config, '--tag', deploy.tag, '--message', deploy.marker];
183
+ const lines = [];
184
+ try {
185
+ await this.wrangler(deploy.pkg, argv, line => {
186
+ lines.push(line);
187
+ process.stderr.write(line + '\n');
188
+ });
189
+ }
190
+ catch (e) {
191
+ const tail = lines.filter(l => /error|✘/i.test(l)).slice(-4);
192
+ throw new Error(e.message + (tail.length ? ':\n' + tail.map(l => ` ${l.trim()}`).join('\n') : ''), {
193
+ cause: e,
194
+ });
195
+ }
196
+ }
197
+ /**
198
+ * `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
199
+ * and through `npx` when it is not - so a package that never added it as a dependency still
200
+ * deploys.
201
+ */
202
+ async wrangler(pkg, argv, onLine) {
203
+ const options = { cwd: pkg.dirname, app: this.app, logLevel: 'silent', onLine };
204
+ try {
205
+ await runBin('wrangler', argv, options);
206
+ }
207
+ catch (e) {
208
+ if (!/was not found/.test(e?.message ?? ''))
209
+ throw e;
210
+ await runBin('npx', ['--yes', 'wrangler', ...argv], options);
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,38 @@
1
+ import type { 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 {
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
+ }
30
+ /**
31
+ * **Cloudflare, as a publish target** - a static site on Pages, or a Worker with its assets.
32
+ *
33
+ * Opt-in like `docker`, so no `claims`: a package deploys here only by naming `"cloudflare"` in
34
+ * its own `publish.target`. What that buys over a workflow running `wrangler` on its own is what
35
+ * every target gets from `publish`: `publish.skip`, `--dry-run` and `--json`, and a deploy that
36
+ * happens once per version rather than once per merge.
37
+ */
38
+ 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
+ };
@@ -0,0 +1,2 @@
1
+ export * from './cloudflare.target.js';
2
+ export * from './cloudflare-publish.service.js';
@@ -0,0 +1,2 @@
1
+ export * from './cloudflare.target.js';
2
+ export * from './cloudflare-publish.service.js';
@@ -1,6 +1,7 @@
1
1
  import colors from 'ansi-colors';
2
2
  import EasyTable from 'easy-table';
3
3
  import { registerCommand } from '../interfaces/rman-config.interface.js';
4
+ import { fitTable } from '../utils/fit-table.js';
4
5
  import { packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
5
6
  import { isNamedGroupKey, isSoloGroupKey, ROOT_GROUP_KEY } from '../utils/version-group.js';
6
7
  /** `[options...]` is yargs-meaningless - its variadic marker is two dots, and this command declares
@@ -185,7 +186,7 @@ function printTable(items, withRoot) {
185
186
  table.cell('Path', it.location);
186
187
  table.newRow();
187
188
  }
188
- console.log(table.toString().trim());
189
+ console.log(fitTable(table.toString().trim(), process.stdout.isTTY ? process.stdout.columns : undefined));
189
190
  console.log('');
190
191
  console.log(colors.gray(`${items.length - (withRoot ? 1 : 0)} Package(s) found`));
191
192
  }
@@ -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
  /**
@@ -70,6 +70,11 @@ declare const config: {
70
70
  * `--message` is `commitMessage`'s flag and is declared above; the rest are settings a repository
71
71
  * states once, not things a single run overrides.
72
72
  */
73
+ skip: {
74
+ target: "config";
75
+ describe: string;
76
+ type: "boolean";
77
+ };
73
78
  commitMessage: {
74
79
  target: "config";
75
80
  describe: string;
@@ -271,6 +276,11 @@ declare const versionCommand: (app: import("../index.js").RmanApplication) => {
271
276
  * `--message` is `commitMessage`'s flag and is declared above; the rest are settings a repository
272
277
  * states once, not things a single run overrides.
273
278
  */
279
+ skip: {
280
+ target: "config";
281
+ describe: string;
282
+ type: "boolean";
283
+ };
274
284
  commitMessage: {
275
285
  target: "config";
276
286
  describe: string;
@@ -4,6 +4,7 @@ import EasyTable from 'easy-table';
4
4
  import { registerCommand } from '../interfaces/rman-config.interface.js';
5
5
  import { VersionPlanService } from '../services/version-plan.service.js';
6
6
  import { assertAllowedBranch, branchGuardOptions, readBranchGuardOptions } from '../utils/branch-guard.js';
7
+ import { fitTable } from '../utils/fit-table.js';
7
8
  import { packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
8
9
  import { isNamedGroupKey, isSoloGroupKey, ROOT_GROUP_KEY } from '../utils/version-group.js';
9
10
  /**
@@ -93,6 +94,13 @@ const config = {
93
94
  * `--message` is `commitMessage`'s flag and is declared above; the rest are settings a repository
94
95
  * states once, not things a single run overrides.
95
96
  */
97
+ skip: {
98
+ target: 'config',
99
+ describe: 'Whether this package takes a version at all. Unset, a package kept out of publishing ' +
100
+ '(publish.skip, or private) is left out; true leaves out one that publishes, false versions ' +
101
+ 'one that does not.',
102
+ type: 'boolean',
103
+ },
96
104
  commitMessage: {
97
105
  target: 'config',
98
106
  describe: 'The commit message for each group this run commits - "{version}" is substituted when a ' +
@@ -304,7 +312,7 @@ function printPlan(entries) {
304
312
  for (const e of members)
305
313
  printPlanRow(table, e);
306
314
  }
307
- console.log(table.toString().trim());
315
+ console.log(fitTable(table.toString().trim(), process.stdout.isTTY ? process.stdout.columns : undefined));
308
316
  }
309
317
  /**
310
318
  * The plan, split into the blocks a delimiter is drawn between: the **repository root** first, then
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.21.1';
1
+ export const version = '2.23.0';
@@ -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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.21.1",
4
+ "version": "2.23.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -71,6 +71,12 @@ export declare abstract class VersionPlanService {
71
71
  * disagree about which commits are unreleased.
72
72
  */
73
73
  getPlan(repository: Repository, options?: VersionPlanService.Options): Promise<VersionPlanService.Entry[]>;
74
+ /**
75
+ * Why `pkg` takes no version at all, or `undefined` when it does: `.rmanrc "version.skip"` when
76
+ * it says so, and otherwise whether it has been kept out of publishing - `publish.skip`, or every
77
+ * target that ships it leaving it out (npm's `private`).
78
+ */
79
+ protected notVersionedReason(pkg: Package): string | undefined;
74
80
  /**
75
81
  * The commit a package's changes are measured **since** - its last release boundary, or
76
82
  * `undefined` for a package that has never been released (the caller then reads the whole
@@ -1,6 +1,7 @@
1
1
  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
+ import { skipReasonFor, targetsOf } from '../core/interfaces/publish-target.js';
4
5
  import { GitHelper } from '../utils/git.js';
5
6
  import { filterPackages } from '../utils/package-filter.js';
6
7
  import { findLastReleaseVersion, formatCalendarVersion, usesCalendarVersion } from '../utils/release-version.js';
@@ -121,8 +122,27 @@ export class VersionPlanService {
121
122
  * `usesCalendarVersion`'s "one line" case, and the root reported `0.0.5 -> 2.1.6` on the
122
123
  * *semver* path instead of the calendar version it takes with four.
123
124
  */
124
- const eligible = options.ignoreDirty ? packages.filter(pkg => !dirty.includes(pkg)) : packages;
125
- for (const pkg of options.ignoreDirty ? dirty : []) {
125
+ /** Not versioned at all - see `notVersionedReason`. Out before `eligible`, for the reason a
126
+ * skipped dirty package is: a number its group's other members get cannot come from it. */
127
+ const unversioned = new Map();
128
+ for (const pkg of packages) {
129
+ const reason = this.notVersionedReason(pkg);
130
+ if (reason)
131
+ unversioned.set(pkg, reason);
132
+ }
133
+ for (const [pkg, reason] of unversioned) {
134
+ entries.set(pkg.name, {
135
+ package: pkg,
136
+ groupKey: this.resolveGroupKey(pkg),
137
+ group: this.groupLabel(this.resolveGroupKey(pkg)),
138
+ status: 'skip',
139
+ from: pkg.version,
140
+ reason,
141
+ });
142
+ }
143
+ const versioned = packages.filter(pkg => !unversioned.has(pkg));
144
+ const eligible = options.ignoreDirty ? versioned.filter(pkg => !dirty.includes(pkg)) : versioned;
145
+ for (const pkg of options.ignoreDirty ? dirty.filter(d => !unversioned.has(d)) : []) {
126
146
  entries.set(pkg.name, {
127
147
  package: pkg,
128
148
  groupKey: this.resolveGroupKey(pkg),
@@ -185,7 +205,8 @@ export class VersionPlanService {
185
205
  if (!options.ignoreDirty) {
186
206
  for (const pkg of dirty) {
187
207
  const entry = entries.get(pkg.name);
188
- if (!entry)
208
+ /** A package that is not versioned has nothing to protect from a dirty tree. */
209
+ if (!entry || unversioned.has(pkg))
189
210
  continue;
190
211
  entry.status = 'error';
191
212
  entry.reason = entry.reason ? `uncommitted local changes (${entry.reason})` : 'uncommitted local changes';
@@ -193,6 +214,46 @@ export class VersionPlanService {
193
214
  }
194
215
  return result;
195
216
  }
217
+ /**
218
+ * Why `pkg` takes no version at all, or `undefined` when it does: `.rmanrc "version.skip"` when
219
+ * it says so, and otherwise whether it has been kept out of publishing - `publish.skip`, or every
220
+ * target that ships it leaving it out (npm's `private`).
221
+ */
222
+ /* **A version nothing publishes is a number nobody can install**, and that is the user's reason
223
+ * for reversing the old rule that `version` never reads `publish.skip`. Measured on
224
+ * `panates/syncbridge`: fourteen packages kept out of publishing were still bumped, tagged and
225
+ * written into changelogs on every release - tags with nothing on the registry behind them, and a
226
+ * version line that had jumped by the time publishing was switched back on.
227
+ *
228
+ * `version.skip` decides first, both ways: `true` holds back a package that does publish, `false`
229
+ * versions one that does not - an application deployed by other means, whose number still
230
+ * matters. A monorepo's root never reaches here; its version is the repository's release
231
+ * identity, decided by `buildRootEntry`.
232
+ *
233
+ * **A package no target ships to is still versioned**, deliberately. Tried the other way first,
234
+ * and it stopped versioning every package of a repository that has no publish target at all - one
235
+ * that versions with rman and publishes by other means, or a technology rman has no target for -
236
+ * which is most of the core's own spec fixtures. Only a package kept out *on purpose* is left
237
+ * out. */
238
+ notVersionedReason(pkg) {
239
+ const declared = pkg.config.version?.skip;
240
+ if (declared === true)
241
+ return 'excluded via .rmanrc "version.skip"';
242
+ if (declared === false)
243
+ return undefined;
244
+ if (pkg.config.publish?.skip)
245
+ return 'not published - .rmanrc "publish.skip"';
246
+ const app = pkg.repository?.app;
247
+ if (!app)
248
+ return undefined;
249
+ const targets = targetsOf(app, pkg);
250
+ if (!targets.length)
251
+ return undefined;
252
+ const reasons = targets.map(target => skipReasonFor(pkg, target));
253
+ if (reasons.every(Boolean))
254
+ return `not published - ${reasons[0]}`;
255
+ return undefined;
256
+ }
196
257
  /**
197
258
  * **Which planner answers for one package: its own technology's.**
198
259
  *
@@ -468,7 +529,9 @@ export class VersionPlanService {
468
529
  const source = worklist.shift();
469
530
  for (const pkg of packages) {
470
531
  const entry = entries.get(pkg.name);
471
- if (entry.status === 'bump' || entry.groupKey === source.groupKey)
532
+ /** A skipped package stays skipped: it is out of the release on purpose (not published, or
533
+ * dirty under `--ignore-dirty`), and a dependency moving does not change that. */
534
+ if (entry.status === 'bump' || entry.status === 'skip' || entry.groupKey === source.groupKey)
472
535
  continue;
473
536
  if (!pkg.dependencies.includes(source.package))
474
537
  continue;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A rendered `easy-table` fitted to a terminal `columns` wide: a row wider than that has its **last
3
+ * column** continued on further lines, each starting at that column, and a delimiter line is cut at
4
+ * the edge. Anything that already fits is left exactly as it was.
5
+ *
6
+ * `columns` is `undefined` when the output is not a terminal, and then nothing changes - a file or a
7
+ * pipe has no edge to wrap at, and a reader of one wants each row on one line.
8
+ */
9
+ export declare function fitTable(text: string, columns: number | undefined): string;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * A rendered `easy-table` fitted to a terminal `columns` wide: a row wider than that has its **last
3
+ * column** continued on further lines, each starting at that column, and a delimiter line is cut at
4
+ * the edge. Anything that already fits is left exactly as it was.
5
+ *
6
+ * `columns` is `undefined` when the output is not a terminal, and then nothing changes - a file or a
7
+ * pipe has no edge to wrap at, and a reader of one wants each row on one line.
8
+ */
9
+ /* **The terminal wrapping it is what this replaces**, and what that did to a table: measured on
10
+ * `panates/syncbridge`, `rman version`'s root row carries a 70-character reason, so the row and the
11
+ * delimiter under the header - as wide as the widest cell - both ran past the edge and continued at
12
+ * column 0, breaking the table into lines the eye could not follow.
13
+ *
14
+ * **Only the last column moves**, because it is the free-text one in every table rman prints
15
+ * (`Reason`, `Path`) - the columns before it are short, fixed values whose alignment is the point.
16
+ * Where it starts is read off the first delimiter line easy-table draws, the one place the column
17
+ * boundaries are spelled out without colour codes in the way.
18
+ *
19
+ * A row whose last column would get fewer than `MIN_WIDTH` characters is left alone: wrapping into a
20
+ * sliver is worse than the terminal's own wrap. */
21
+ export function fitTable(text, columns) {
22
+ if (!columns)
23
+ return text;
24
+ const lines = text.split('\n');
25
+ const delimiter = lines.find(line => /^[\s-]*-[\s-]*$/.test(line));
26
+ if (!delimiter)
27
+ return text;
28
+ const start = delimiter.search(/-+\s*$/);
29
+ const width = columns - start;
30
+ /** Every cell is padded to its column, the last one included, so a row of short text is still as
31
+ * wide as the widest - past the edge, those spaces wrapped into a blank line under each row.
32
+ * Trimmed and cut even where the last column is too narrow to wrap into. */
33
+ const trimmed = lines.map(line => (/^[\s-]+$/.test(line) ? line.slice(0, columns) : line.trimEnd()));
34
+ if (start <= 0 || width < MIN_WIDTH)
35
+ return trimmed.join('\n');
36
+ return trimmed
37
+ .flatMap(line => {
38
+ if (visibleLength(line) <= columns)
39
+ return [line];
40
+ const at = rawIndexOf(line, start);
41
+ const head = line.slice(0, at);
42
+ const tail = line.slice(at);
43
+ /** A cell painted as a whole (`colors.gray(reason)`) keeps its colour on every line. */
44
+ // eslint-disable-next-line no-control-regex
45
+ const paint = /^(?:\x1b\[[0-9;]*m)+/.exec(tail)?.[0] ?? '';
46
+ const chunks = wrapWords(stripAnsi(tail), width);
47
+ const pad = ' '.repeat(start);
48
+ return chunks.map((chunk, i) => (i ? pad : head) + (paint ? `${paint}${chunk}\x1b[0m` : chunk));
49
+ })
50
+ .join('\n');
51
+ }
52
+ const MIN_WIDTH = 20;
53
+ // eslint-disable-next-line no-control-regex
54
+ const ANSI = /\x1b\[[0-9;]*m/g;
55
+ function stripAnsi(text) {
56
+ return text.replace(ANSI, '');
57
+ }
58
+ function visibleLength(text) {
59
+ return stripAnsi(text).length;
60
+ }
61
+ /** The index in `line` of its `visible`-th visible character, colour codes skipped. */
62
+ function rawIndexOf(line, visible) {
63
+ let seen = 0;
64
+ let i = 0;
65
+ while (i < line.length && seen < visible) {
66
+ if (line[i] === '\x1b') {
67
+ const end = line.indexOf('m', i);
68
+ i = end < 0 ? line.length : end + 1;
69
+ continue;
70
+ }
71
+ seen++;
72
+ i++;
73
+ }
74
+ /** Colour codes sitting right at the boundary belong to the cell after it. */
75
+ return i;
76
+ }
77
+ /** `text` cut at spaces into lines of at most `width` - a word longer than that on a line of its own. */
78
+ function wrapWords(text, width) {
79
+ const lines = [];
80
+ let current = '';
81
+ for (const word of text.trim().split(/\s+/)) {
82
+ if (current && current.length + 1 + word.length > width) {
83
+ lines.push(current);
84
+ current = word;
85
+ }
86
+ else
87
+ current = current ? `${current} ${word}` : word;
88
+ }
89
+ if (current)
90
+ lines.push(current);
91
+ return lines;
92
+ }