rman 1.0.4 → 1.0.6

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/README.md CHANGED
@@ -91,7 +91,7 @@ worked examples of every single command, see **[docs/cli.md](docs/cli.md)**.
91
91
  | [`diff [package]`](#rman-diff-package) | Shows the git diff since a package's (or the repo's) last release tag. |
92
92
  | [`changelog`](#rman-changelog) | Generates a changelog per package from unreleased commits. |
93
93
  | [`version [bump]`](#rman-version-bump) | Bumps versions of changed packages (and their dependents). |
94
- | [`publish`](#rman-publish) | Publishes every non-private package not already on the registry. |
94
+ | [`publish`](#rman-publish) | Publishes every package to its configured target(s) - npm and/or Docker. |
95
95
  | [`import <path>`](#rman-import-path) | Imports an external git repository as a new package, with history. |
96
96
 
97
97
  Options shared across several commands:
@@ -264,7 +264,8 @@ algorithm, prerelease semantics, and `"workspace:"` dependency-range handling.
264
264
 
265
265
  ### `rman publish`
266
266
 
267
- Publishes every non-private package whose local version isn't already on the registry.
267
+ Publishes every package to its configured target(s) - `npm` by default, or whatever each package's
268
+ own `.rmanrc "publish.target"` says (`"npm"`, `"docker"`, or both).
268
269
 
269
270
  ```bash
270
271
  rman publish # show the plan, then ask for confirmation
@@ -275,12 +276,17 @@ rman publish --tag next
275
276
  rman publish --otp 123456
276
277
  rman publish --registry https://registry.example.com --userconfig ./ci.npmrc
277
278
  rman publish --package-manager pnpm
279
+ rman publish --target docker # only the packages configured for the "docker" target
278
280
  ```
279
281
 
280
282
  A `"workspace:*"`/`"workspace:^"`/`"workspace:~"` dependency range is automatically rewritten to a
281
283
  real, registry-consumable range immediately before each package's publish, and restored right
282
284
  after - see [docs/api.md#publishservice](docs/api.md#publishservice).
283
285
 
286
+ A package opts into building/pushing a Docker image via `.rmanrc "publish.target": ["docker"]` plus
287
+ a `"publish.docker"` block (`image`, `platforms`, `buildContexts`, `buildArgs`, ...) - see
288
+ [docs/cli/publish.md#docker-publishing-publishdocker](docs/cli/publish.md#docker-publishing-publishdocker).
289
+
284
290
  ### `rman import <path>`
285
291
 
286
292
  Imports an external git repository as a new package, preserving its **entire commit history**
package/cli.js CHANGED
@@ -65,7 +65,7 @@ export async function runCli(options) {
65
65
  changedCommand.initCli(repository, program);
66
66
  diffCommand.initCli(repository, program);
67
67
  importCommand.initCli(repository, program);
68
- program.demandCommand(1).strict().completion();
68
+ program.demandCommand(1).strict().recommendCommands().completion();
69
69
  if (!_argv.length)
70
70
  program.showHelp();
71
71
  else
@@ -1,17 +1,19 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import colors from 'ansi-colors';
3
3
  import { CiService } from '../services/ci.service.js';
4
+ import { DockerPublishService } from '../services/docker-publish.service.js';
4
5
  import { PublishService } from '../services/publish.service.js';
5
6
  import { applyBranchGuardOptions, assertAllowedBranch, readBranchGuardOptions } from '../utils/branch-guard.js';
6
7
  import { applyPackageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
7
8
  export function initCli(repository, program) {
8
9
  program.command({
9
10
  command: 'publish',
10
- describe: 'Publishes every non-private package whose local version is not already on the registry',
11
+ describe: 'Publishes every package to its configured target(s) (npm by default, or .rmanrc "publish.target")',
11
12
  builder: cmd => applyBranchGuardOptions(applyPackageFilterOptions(cmd))
12
13
  .example('$0 publish', '# Show the plan, then ask for confirmation')
13
14
  .example('$0 publish --yes', '# Publish immediately, no confirmation')
14
15
  .example('$0 publish --dry-run', '# Only show the plan, never publish')
16
+ .example('$0 publish --target docker', '# Only the packages configured for the "docker" target')
15
17
  .option('yes', {
16
18
  alias: 'y',
17
19
  describe: 'Skip the confirmation prompt and publish immediately',
@@ -20,6 +22,14 @@ export function initCli(repository, program) {
20
22
  .option('dry-run', {
21
23
  describe: 'Only show the plan - never publishes, regardless of --yes',
22
24
  type: 'boolean',
25
+ })
26
+ .option('target', {
27
+ describe: 'Restrict this run to just these publish target(s) ("npm"/"docker", repeatable) - default: ' +
28
+ 'every target each package itself is configured for (.rmanrc "publish.target", "npm" when unset). ' +
29
+ 'A package that opts into "docker" but has no "publish.docker" config errors clearly instead of ' +
30
+ 'being silently skipped.',
31
+ type: 'array',
32
+ choices: ['npm', 'docker'],
23
33
  })
24
34
  .option('ignore-dirty', {
25
35
  describe: 'Exclude a package with uncommitted local changes instead of aborting the whole run',
@@ -53,27 +63,52 @@ export function initCli(repository, program) {
53
63
  describe: "Subdirectory to publish from, relative to each package's own directory - only consulted when a " +
54
64
  'package has no "publishConfig.directory" of its own (that always wins when present)',
55
65
  type: 'string',
66
+ })
67
+ .option('docker-namespace', {
68
+ describe: 'Prefixed onto a bare (no "/") "publish.docker.image" - default: the DOCKERHUB_NAMESPACE ' +
69
+ 'environment variable.',
70
+ type: 'string',
56
71
  }),
57
72
  handler: async (args) => {
58
73
  await assertAllowedBranch(repository, readBranchGuardOptions(args));
59
- const options = {
74
+ const targets = resolveTargets(args.target);
75
+ const explicitDockerTarget = !!args.target?.length && targets.has('docker');
76
+ const ignoreDirty = args.ignoreDirty;
77
+ const npmOptions = {
60
78
  ...readPackageFilterOptions(args),
61
- ignoreDirty: args.ignoreDirty,
79
+ ignoreDirty,
62
80
  registry: args.registry,
63
81
  userconfig: args.userconfig,
64
82
  };
65
- const plan = await PublishService.getPlan(repository, options);
66
- printPlan(plan);
67
- const errors = plan.filter(e => e.status === 'error');
83
+ const dockerOptions = {
84
+ ...readPackageFilterOptions(args),
85
+ ignoreDirty,
86
+ namespace: args.dockerNamespace,
87
+ };
88
+ const npmPlan = targets.has('npm') ? await PublishService.getPlan(repository, npmOptions) : [];
89
+ const dockerPlan = targets.has('docker') ? await DockerPublishService.getPlan(repository, dockerOptions) : [];
90
+ printPlan(npmPlan);
91
+ printPlan(dockerPlan, 'docker');
92
+ if (explicitDockerTarget && !dockerPlan.length) {
93
+ const message = '--target docker was given, but no package\'s .rmanrc configures "publish.docker".';
94
+ console.log(colors.red(message));
95
+ const err = new Error(message);
96
+ err.logged = true;
97
+ throw err;
98
+ }
99
+ const errors = [...npmPlan, ...dockerPlan].filter(e => e.status === 'error');
68
100
  if (errors.length) {
69
- const message = `${errors.length} package(s) have uncommitted local changes ` +
70
- '(pass --ignore-dirty to exclude them instead of aborting)';
101
+ const allDirty = errors.every(e => e.reason === 'uncommitted local changes');
102
+ const message = allDirty
103
+ ? `${errors.length} package(s) have uncommitted local changes ` +
104
+ '(pass --ignore-dirty to exclude them instead of aborting)'
105
+ : `${errors.length} package(s) failed to prepare for publish - see the errors above`;
71
106
  console.log(colors.red(message));
72
107
  const err = new Error(message);
73
108
  err.logged = true;
74
109
  throw err;
75
110
  }
76
- if (!plan.some(e => e.status === 'publish')) {
111
+ if (!npmPlan.some(e => e.status === 'publish') && !dockerPlan.some(e => e.status === 'publish')) {
77
112
  console.log(colors.gray('Nothing to publish.'));
78
113
  return;
79
114
  }
@@ -89,24 +124,37 @@ export function initCli(repository, program) {
89
124
  }
90
125
  if (!proceed)
91
126
  return;
92
- const applied = await PublishService.applyPlan(repository, plan, {
93
- ...options,
94
- packageManager: args.packageManager,
95
- access: args.access,
96
- tag: args.tag,
97
- otp: args.otp,
98
- contents: args.contents,
99
- });
127
+ const appliedNpm = targets.has('npm')
128
+ ? await PublishService.applyPlan(repository, npmPlan, {
129
+ ...npmOptions,
130
+ packageManager: args.packageManager,
131
+ access: args.access,
132
+ tag: args.tag,
133
+ otp: args.otp,
134
+ contents: args.contents,
135
+ })
136
+ : [];
137
+ const appliedDocker = targets.has('docker') ? await DockerPublishService.applyPlan(repository, dockerPlan) : [];
100
138
  let failed = false;
101
- for (const entry of applied) {
139
+ for (const entry of appliedNpm) {
102
140
  if (entry.status === 'publish') {
103
141
  console.log(colors.green('published'), colors.cyan(entry.package.name), entry.version);
104
142
  }
105
- else if (entry.status === 'error' && plan.find(e => e.package === entry.package)?.status === 'publish') {
143
+ else if (entry.status === 'error' && npmPlan.find(e => e.package === entry.package)?.status === 'publish') {
106
144
  failed = true;
107
145
  console.log(colors.red('failed'), colors.cyan(entry.package.name), colors.red(entry.reason ?? ''));
108
146
  }
109
147
  }
148
+ for (const entry of appliedDocker) {
149
+ if (entry.status === 'publish') {
150
+ console.log(colors.green('published'), colors.gray('[docker]'), colors.cyan(entry.package.name), entry.image);
151
+ }
152
+ else if (entry.status === 'error' &&
153
+ dockerPlan.find(e => e.package === entry.package)?.status === 'publish') {
154
+ failed = true;
155
+ console.log(colors.red('failed'), colors.gray('[docker]'), colors.cyan(entry.package.name), colors.red(entry.reason ?? ''));
156
+ }
157
+ }
110
158
  if (failed) {
111
159
  const err = new Error('"publish" failed');
112
160
  err.logged = true;
@@ -115,9 +163,15 @@ export function initCli(repository, program) {
115
163
  },
116
164
  });
117
165
  }
118
- function printPlan(entries) {
166
+ function resolveTargets(input) {
167
+ if (!input?.length)
168
+ return new Set(['npm', 'docker']);
169
+ return new Set(input);
170
+ }
171
+ function printPlan(entries, label) {
172
+ const prefix = label ? colors.gray(`[${label}] `) : '';
119
173
  for (const e of entries) {
120
- const name = colors.cyan(e.package.name);
174
+ const name = prefix + colors.cyan(e.package.name);
121
175
  switch (e.status) {
122
176
  case 'publish':
123
177
  console.log(colors.green('publish'), name, e.version, colors.gray(e.reason ?? ''));
@@ -1,5 +1,6 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import colors from 'ansi-colors';
3
+ import EasyTable from 'easy-table';
3
4
  import { VersionService } from '../services/version.service.js';
4
5
  import { applyBranchGuardOptions, assertAllowedBranch, readBranchGuardOptions } from '../utils/branch-guard.js';
5
6
  import { applyPackageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
@@ -29,6 +30,13 @@ export function initCli(repository, program) {
29
30
  type: 'boolean',
30
31
  })
31
32
  .conflicts('show', 'interactive')
33
+ .option('yes', {
34
+ alias: 'y',
35
+ describe: 'Skip the confirmation prompt and apply the computed plan immediately - auto-detected ' +
36
+ 'severity included, no explicit bump keyword required (same idea as "publish --yes").',
37
+ type: 'boolean',
38
+ })
39
+ .conflicts('yes', 'interactive')
32
40
  .option('ignore-dirty', {
33
41
  describe: 'Exclude a package with uncommitted local changes instead of aborting the whole run',
34
42
  type: 'boolean',
@@ -82,12 +90,12 @@ export function initCli(repository, program) {
82
90
  console.log(colors.gray('Preview only (--show) - nothing was written.'));
83
91
  return;
84
92
  }
85
- let apply = !!bump;
93
+ let apply = !!bump || !!args.yes;
86
94
  if (args.interactive) {
87
95
  apply = await confirm('Apply these changes?');
88
96
  }
89
- else if (!bump) {
90
- console.log(colors.gray('Run again with an explicit bump, or --interactive, to apply.'));
97
+ else if (!apply) {
98
+ console.log(colors.gray('Run again with an explicit bump, --interactive, or --yes, to apply.'));
91
99
  return;
92
100
  }
93
101
  if (!apply)
@@ -106,23 +114,29 @@ export function initCli(repository, program) {
106
114
  });
107
115
  }
108
116
  function printPlan(entries) {
117
+ const table = new EasyTable();
109
118
  for (const e of entries) {
110
- const name = colors.cyan(e.package.name);
111
- const group = colors.gray(`(${e.group})`);
112
- switch (e.status) {
113
- case 'bump':
114
- console.log(colors.green('bump'), name, group, e.from, '->', colors.yellow(e.to), colors.gray(e.reason ?? ''));
115
- break;
116
- case 'no-change':
117
- console.log(colors.gray('no-change'), name, group, e.from);
118
- break;
119
- case 'skip':
120
- console.log(colors.cyan('skip'), name, group, colors.gray(e.reason ?? ''));
121
- break;
122
- case 'error':
123
- console.log(colors.red('error'), name, group, colors.red(e.reason ?? ''));
124
- break;
125
- }
119
+ table.cell('Status', statusLabel(e.status));
120
+ table.cell('Package', colors.cyan(e.package.name));
121
+ table.cell('Group', colors.gray(`(${e.group})`));
122
+ table.cell('From', e.from);
123
+ table.cell('', e.status === 'bump' ? '->' : '');
124
+ table.cell('To', e.status === 'bump' ? colors.yellow(e.to) : '');
125
+ table.cell('Reason', e.status === 'error' ? colors.red(e.reason ?? '') : colors.gray(e.reason ?? ''));
126
+ table.newRow();
127
+ }
128
+ console.log(table.toString().trim());
129
+ }
130
+ function statusLabel(status) {
131
+ switch (status) {
132
+ case 'bump':
133
+ return colors.green('bump');
134
+ case 'no-change':
135
+ return colors.gray('no-change');
136
+ case 'skip':
137
+ return colors.cyan('skip');
138
+ case 'error':
139
+ return colors.red('error');
126
140
  }
127
141
  }
128
142
  async function confirm(question) {
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '1.0.4';
1
+ export const version = '1.0.6';
package/core/config.d.ts CHANGED
@@ -15,6 +15,7 @@ export interface RmanConfig {
15
15
  version?: RmanConfig.VersionOptions;
16
16
  changelog?: RmanConfig.ChangelogOptions;
17
17
  clean?: RmanConfig.CleanOptions;
18
+ publish?: RmanConfig.PublishOptions;
18
19
  /** Keyed by npm script name (e.g. `"build"`, `"lint"`, `"test"`). */
19
20
  run?: Record<string, RmanConfig.RunScriptOptions>;
20
21
  /** Keyed by the in-repo package's own name. */
@@ -55,6 +56,39 @@ export declare namespace RmanConfig {
55
56
  interface PackageOptions {
56
57
  dependencies?: string[] | Record<string, string>;
57
58
  }
59
+ interface PublishOptions {
60
+ /** Which registries `publish` should target for this package - default `['npm']` (every
61
+ * existing repo keeps working unchanged). A package that only ever wants Docker images
62
+ * (typically also `"private": true`, since it's not meant for npm at all) sets `['docker']`;
63
+ * one that publishes both sets `['npm', 'docker']`. */
64
+ target?: PublishTarget | PublishTarget[];
65
+ docker?: DockerPublishOptions;
66
+ }
67
+ type PublishTarget = 'npm' | 'docker';
68
+ /** Required once `"docker"` is one of this package's `publish.target`s - `publish --target
69
+ * docker` errors clearly on a package that opts in here but leaves this out. */
70
+ interface DockerPublishOptions {
71
+ /** DockerHub image name/repository - bare (e.g. `"my-app"`) to be prefixed with
72
+ * `--docker-namespace`/`DOCKERHUB_NAMESPACE`, or already-namespaced (contains a `/`) to use
73
+ * verbatim. */
74
+ image: string;
75
+ /** Relative to the package's own directory. Default `"Dockerfile"`. */
76
+ dockerfile?: string;
77
+ /** Default `["linux/amd64"]`. */
78
+ platforms?: string[];
79
+ /** Build `cwd` override, relative to the repository root - only needed when the Dockerfile's
80
+ * own `COPY`/`ADD` paths expect something other than the package's own directory (rare). */
81
+ cwd?: string;
82
+ /** Named `docker buildx build --build-context <name>=<path>` entries, keyed by name - each
83
+ * path is relative to the package's own directory (or absolute). */
84
+ buildContexts?: Record<string, string>;
85
+ /** `docker buildx build --build-arg <name>=<value>` entries - a value of exactly `"$NAME"`
86
+ * expands to `process.env.NAME` at build time (e.g. to pass a CI secret through). */
87
+ buildArgs?: Record<string, string>;
88
+ /** A file (relative to the package's own directory) whose contents become the DockerHub repo's
89
+ * full description, if present. Default `"DOCKER_README.md"`. */
90
+ readme?: string;
91
+ }
58
92
  }
59
93
  /**
60
94
  * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "1.0.4",
4
+ "version": "1.0.6",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -104,6 +104,25 @@
104
104
  }
105
105
  }
106
106
  },
107
+ "publish": {
108
+ "type": "object",
109
+ "description": "Options for the \"publish\" command/PublishService and DockerPublishService. Per-package cascaded.",
110
+ "additionalProperties": false,
111
+ "properties": {
112
+ "target": {
113
+ "description": "Which registries \"publish\" targets for this package. Default [\"npm\"]. A package that only ships Docker images (typically also \"private\": true) sets [\"docker\"]; one that publishes both sets [\"npm\", \"docker\"].",
114
+ "default": ["npm"],
115
+ "oneOf": [
116
+ { "$ref": "#/definitions/publishTarget" },
117
+ { "type": "array", "items": { "$ref": "#/definitions/publishTarget" } }
118
+ ]
119
+ },
120
+ "docker": {
121
+ "$ref": "#/definitions/dockerPublishConfig",
122
+ "description": "Required once \"docker\" is one of this package's \"publish.target\"s."
123
+ }
124
+ }
125
+ },
107
126
  "run": {
108
127
  "type": "object",
109
128
  "description": "Per-script options for \"run\"/\"build\"/\"test\"/RunService, keyed by npm script name (e.g. \"build\", \"lint\", \"test\").",
@@ -138,6 +157,52 @@
138
157
  { "type": "array", "items": { "type": "string", "minLength": 1 } }
139
158
  ]
140
159
  },
160
+ "publishTarget": {
161
+ "type": "string",
162
+ "enum": ["npm", "docker"]
163
+ },
164
+ "dockerPublishConfig": {
165
+ "type": "object",
166
+ "additionalProperties": false,
167
+ "required": ["image"],
168
+ "properties": {
169
+ "image": {
170
+ "type": "string",
171
+ "minLength": 1,
172
+ "description": "DockerHub image name/repository - bare (e.g. \"my-app\") to be prefixed with --docker-namespace/DOCKERHUB_NAMESPACE, or already-namespaced (contains a \"/\") to use verbatim."
173
+ },
174
+ "dockerfile": {
175
+ "type": "string",
176
+ "default": "Dockerfile",
177
+ "description": "Relative to the package's own directory."
178
+ },
179
+ "platforms": {
180
+ "type": "array",
181
+ "items": { "type": "string" },
182
+ "default": ["linux/amd64"],
183
+ "description": "\"docker buildx build --platform\" targets."
184
+ },
185
+ "cwd": {
186
+ "type": "string",
187
+ "description": "Build cwd override, relative to the repository root - only needed when the Dockerfile's own COPY/ADD paths expect something other than the package's own directory."
188
+ },
189
+ "buildContexts": {
190
+ "type": "object",
191
+ "additionalProperties": { "type": "string" },
192
+ "description": "Named \"docker buildx build --build-context <name>=<path>\" entries, keyed by name - each path relative to the package's own directory (or absolute)."
193
+ },
194
+ "buildArgs": {
195
+ "type": "object",
196
+ "additionalProperties": { "type": "string" },
197
+ "description": "\"docker buildx build --build-arg <name>=<value>\" entries - a value of exactly \"$NAME\" expands to the NAME environment variable at build time."
198
+ },
199
+ "readme": {
200
+ "type": "string",
201
+ "default": "DOCKER_README.md",
202
+ "description": "A file (relative to the package's own directory) whose contents become the DockerHub repo's full description, if present."
203
+ }
204
+ }
205
+ },
141
206
  "runScriptConfig": {
142
207
  "type": "object",
143
208
  "additionalProperties": false,
@@ -0,0 +1,52 @@
1
+ import type { Package } from '../core/package.js';
2
+ import type { Repository } from '../core/repository.js';
3
+ import { type PackageFilterOptions } from '../utils/package-filter.js';
4
+ export declare namespace DockerPublishService {
5
+ /** Injectable "does this tag already exist" check - mainly for tests, so they don't depend on
6
+ * network access or a real Docker daemon. Same shape as `PublishService.Deps.npmViewVersion`. */
7
+ interface Deps {
8
+ imageExists?: (image: string, tag: string) => Promise<boolean>;
9
+ }
10
+ interface Options extends PackageFilterOptions {
11
+ /** A package with uncommitted local changes is excluded (status `'skip'`) instead of aborting
12
+ * the whole plan (status `'error'`) - same as `version`/`publish --target npm`'s own option. */
13
+ ignoreDirty?: boolean;
14
+ /** Prefixed onto a bare (no `/`) `publish.docker.image` - falls back to the
15
+ * `DOCKERHUB_NAMESPACE` environment variable. */
16
+ namespace?: string;
17
+ }
18
+ type ApplyOptions = Options;
19
+ /** One package's outcome in a docker-publish plan - see `getPlan`. */
20
+ interface Entry {
21
+ package: Package;
22
+ version: string;
23
+ status: 'publish' | 'skip' | 'up-to-date' | 'error';
24
+ /** The fully-qualified `<namespace>/<image>` this entry publishes to - unset only when the
25
+ * package's own `publish.docker.image` config is missing entirely (an `'error'` entry). */
26
+ image?: string;
27
+ reason?: string;
28
+ }
29
+ /**
30
+ * Computes what `publish --target docker` *would* do. Unlike the npm side (opt-out via
31
+ * `"private"`), the docker target is opt-in: only packages whose own (cascaded) `.rmanrc
32
+ * "publish.target"` includes `"docker"` are candidates at all - everything else is left out of
33
+ * the plan entirely, not shown as `'skip'`, since most packages in a repo aren't docker images.
34
+ *
35
+ * A candidate missing the required `publish.docker.image` config is `'error'` - a clear, blocking
36
+ * misconfiguration (opted into the target, forgot the config) rather than a silent no-op. A
37
+ * candidate with uncommitted local changes is `'error'` too, unless `options.ignoreDirty`
38
+ * downgrades it to `'skip'` - same rule the npm side uses. Otherwise, whether `<image>:<version>`
39
+ * already exists on the registry (via `docker manifest inspect`, queried concurrently) decides
40
+ * the rest: `'up-to-date'` if so, `'publish'` if not.
41
+ */
42
+ function getPlan(repository: Repository, options?: Options, deps?: Deps): Promise<Entry[]>;
43
+ /**
44
+ * Publishes every `'publish'` entry in `plan`: one `docker login` and one `docker buildx create`
45
+ * up front (each package's own build reuses them), then per package a single `docker buildx
46
+ * build --push` using that package's `publish.docker` config (platforms, named build-contexts,
47
+ * build-args, an optional `cwd` override). A package's `publish.docker.readme` file (default
48
+ * `DOCKER_README.md`), if present, updates the DockerHub repo description afterward. A package's
49
+ * own failure doesn't stop unrelated packages elsewhere in the plan.
50
+ */
51
+ function applyPlan(repository: Repository, plan: Entry[]): Promise<Entry[]>;
52
+ }
@@ -0,0 +1,192 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { exec } from '../utils/exec.js';
6
+ import { GitHelper } from '../utils/git.js';
7
+ import { filterPackages } from '../utils/package-filter.js';
8
+ export var DockerPublishService;
9
+ (function (DockerPublishService) {
10
+ /**
11
+ * Computes what `publish --target docker` *would* do. Unlike the npm side (opt-out via
12
+ * `"private"`), the docker target is opt-in: only packages whose own (cascaded) `.rmanrc
13
+ * "publish.target"` includes `"docker"` are candidates at all - everything else is left out of
14
+ * the plan entirely, not shown as `'skip'`, since most packages in a repo aren't docker images.
15
+ *
16
+ * A candidate missing the required `publish.docker.image` config is `'error'` - a clear, blocking
17
+ * misconfiguration (opted into the target, forgot the config) rather than a silent no-op. A
18
+ * candidate with uncommitted local changes is `'error'` too, unless `options.ignoreDirty`
19
+ * downgrades it to `'skip'` - same rule the npm side uses. Otherwise, whether `<image>:<version>`
20
+ * already exists on the registry (via `docker manifest inspect`, queried concurrently) decides
21
+ * the rest: `'up-to-date'` if so, `'publish'` if not.
22
+ */
23
+ async function getPlan(repository, options = {}, deps = {}) {
24
+ const git = new GitHelper({ cwd: repository.dirname });
25
+ const packages = filterPackages(repository.getPackages({ toposort: true }), options).filter(targetsDocker);
26
+ const dirtyFiles = await git.listDirtyFiles({ absolute: true });
27
+ const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
28
+ const imageExists = deps.imageExists ?? defaultImageExists;
29
+ const entries = new Map();
30
+ const toCheck = [];
31
+ for (const pkg of packages) {
32
+ const docker = pkg.config.publish?.docker;
33
+ if (!docker?.image) {
34
+ entries.set(pkg.name, {
35
+ package: pkg,
36
+ version: pkg.version,
37
+ status: 'error',
38
+ reason: '"docker" is a publish target but "publish.docker.image" is not configured',
39
+ });
40
+ continue;
41
+ }
42
+ let image;
43
+ try {
44
+ image = resolveImageRef(docker.image, options.namespace);
45
+ }
46
+ catch (e) {
47
+ entries.set(pkg.name, { package: pkg, version: pkg.version, status: 'error', reason: e.message });
48
+ continue;
49
+ }
50
+ if (isDirty(pkg)) {
51
+ entries.set(pkg.name, {
52
+ package: pkg,
53
+ version: pkg.version,
54
+ image,
55
+ status: options.ignoreDirty ? 'skip' : 'error',
56
+ reason: 'uncommitted local changes',
57
+ });
58
+ continue;
59
+ }
60
+ toCheck.push({ pkg, image });
61
+ }
62
+ await Promise.all(toCheck.map(async ({ pkg, image }) => {
63
+ const exists = await imageExists(image, pkg.version);
64
+ entries.set(pkg.name, {
65
+ package: pkg,
66
+ version: pkg.version,
67
+ image,
68
+ status: exists ? 'up-to-date' : 'publish',
69
+ reason: exists ? `registry already has ${image}:${pkg.version}` : 'never published',
70
+ });
71
+ }));
72
+ return packages.map(pkg => entries.get(pkg.name));
73
+ }
74
+ DockerPublishService.getPlan = getPlan;
75
+ /**
76
+ * Publishes every `'publish'` entry in `plan`: one `docker login` and one `docker buildx create`
77
+ * up front (each package's own build reuses them), then per package a single `docker buildx
78
+ * build --push` using that package's `publish.docker` config (platforms, named build-contexts,
79
+ * build-args, an optional `cwd` override). A package's `publish.docker.readme` file (default
80
+ * `DOCKER_README.md`), if present, updates the DockerHub repo description afterward. A package's
81
+ * own failure doesn't stop unrelated packages elsewhere in the plan.
82
+ */
83
+ async function applyPlan(repository, plan) {
84
+ const toPublish = plan.filter(e => e.status === 'publish');
85
+ if (!toPublish.length)
86
+ return plan;
87
+ await dockerLogin(repository.dirname);
88
+ await exec('docker buildx create --use', { cwd: repository.dirname, stdio: 'inherit', throwOnError: false });
89
+ const result = [];
90
+ for (const entry of plan) {
91
+ if (entry.status !== 'publish') {
92
+ result.push(entry);
93
+ continue;
94
+ }
95
+ try {
96
+ await buildAndPush(repository, entry);
97
+ await updateDescription(entry);
98
+ result.push(entry);
99
+ }
100
+ catch (e) {
101
+ result.push({ ...entry, status: 'error', reason: e.message });
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+ DockerPublishService.applyPlan = applyPlan;
107
+ })(DockerPublishService || (DockerPublishService = {}));
108
+ const execFileAsync = promisify(execFile);
109
+ /** `docker manifest inspect <image>:<tag>` - `false` for any failure (tag doesn't exist yet, no
110
+ * network, not logged in, ...), same catch-everything shape as `PublishService`'s own
111
+ * `defaultNpmViewVersion`. */
112
+ async function defaultImageExists(image, tag) {
113
+ try {
114
+ await execFileAsync('docker', ['manifest', 'inspect', `${image}:${tag}`]);
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ function targetsDocker(pkg) {
122
+ const target = pkg.config.publish?.target;
123
+ const targets = Array.isArray(target) ? target : target ? [target] : ['npm'];
124
+ return targets.includes('docker');
125
+ }
126
+ function resolveImageRef(image, namespaceOverride) {
127
+ if (image.includes('/'))
128
+ return image;
129
+ const namespace = namespaceOverride || process.env.DOCKERHUB_NAMESPACE;
130
+ if (!namespace) {
131
+ throw new Error(`"publish.docker.image" ("${image}") has no namespace and no --docker-namespace/DOCKERHUB_NAMESPACE is set`);
132
+ }
133
+ return `${namespace}/${image}`;
134
+ }
135
+ /** A value of exactly `"$NAME"` expands to `process.env.NAME` (empty string if unset) - anything
136
+ * else (including a value with `$` only as part of a larger string) is passed through verbatim. */
137
+ function expandEnvValue(value) {
138
+ const match = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
139
+ return match ? (process.env[match[1]] ?? '') : value;
140
+ }
141
+ async function dockerLogin(cwd) {
142
+ const username = process.env.DOCKERHUB_USERNAME;
143
+ const password = process.env.DOCKERHUB_PASSWORD;
144
+ if (!username || !password) {
145
+ throw new Error('DOCKERHUB_USERNAME/DOCKERHUB_PASSWORD environment variables are required to publish to Docker');
146
+ }
147
+ await exec(`echo "${password}" | docker login --username "${username}" --password-stdin`, { cwd, stdio: 'inherit' });
148
+ }
149
+ async function buildAndPush(repository, entry) {
150
+ const pkg = entry.package;
151
+ const docker = pkg.config.publish.docker;
152
+ const platforms = docker.platforms?.length ? docker.platforms : ['linux/amd64'];
153
+ const dockerfile = path.resolve(pkg.dirname, docker.dockerfile || 'Dockerfile');
154
+ const cwd = docker.cwd ? path.resolve(repository.dirname, docker.cwd) : pkg.dirname;
155
+ const args = ['buildx', 'build', '--platform', platforms.join(',')];
156
+ for (const [name, dir] of Object.entries(docker.buildContexts ?? {})) {
157
+ args.push('--build-context', `${name}="${path.resolve(pkg.dirname, dir)}"`);
158
+ }
159
+ for (const [name, value] of Object.entries(docker.buildArgs ?? {})) {
160
+ args.push('--build-arg', `${name}="${expandEnvValue(value)}"`);
161
+ }
162
+ args.push('-f', `"${dockerfile}"`, '-t', `"${entry.image}:${entry.version}"`, '-t', `"${entry.image}:latest"`, '--push', '.');
163
+ await exec(`docker ${args.join(' ')}`, { cwd, stdio: 'inherit' });
164
+ }
165
+ async function updateDescription(entry) {
166
+ const pkg = entry.package;
167
+ const docker = pkg.config.publish.docker;
168
+ const readmeFile = path.join(pkg.dirname, docker.readme || 'DOCKER_README.md');
169
+ if (!fs.existsSync(readmeFile))
170
+ return;
171
+ const username = process.env.DOCKERHUB_USERNAME;
172
+ const password = process.env.DOCKERHUB_PASSWORD;
173
+ const loginRes = await fetch('https://hub.docker.com/v2/users/login/', {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({ username, password }),
177
+ });
178
+ if (!loginRes.ok)
179
+ throw new Error(`DockerHub login (for description update) failed: ${loginRes.status}`);
180
+ const { token } = await loginRes.json();
181
+ const slashIdx = entry.image.indexOf('/');
182
+ const namespace = entry.image.slice(0, slashIdx);
183
+ const imageName = entry.image.slice(slashIdx + 1);
184
+ const readme = fs.readFileSync(readmeFile, 'utf-8');
185
+ const res = await fetch(`https://hub.docker.com/v2/repositories/${namespace}/${imageName}/`, {
186
+ method: 'PATCH',
187
+ headers: { Authorization: `JWT ${token}`, 'Content-Type': 'application/json' },
188
+ body: JSON.stringify({ description: pkg.json.description, full_description: readme }),
189
+ });
190
+ if (!res.ok)
191
+ throw new Error(`DockerHub description update failed: ${res.status}`);
192
+ }
@@ -1,3 +1,4 @@
1
+ import type { RmanConfig } from '../core/config.js';
1
2
  import type { Repository } from '../core/repository.js';
2
3
  import { type PackageFilterOptions } from '../utils/package-filter.js';
3
4
  export declare namespace ListService {
@@ -18,6 +19,12 @@ export declare namespace ListService {
18
19
  /** In-repo package names this one depends on - enough to build a dependency graph without a
19
20
  * second call, e.g. `Object.fromEntries(items.map(i => [i.name, i.dependencies]))`. */
20
21
  dependencies: string[];
22
+ /** This package's own (cascaded) `.rmanrc "publish.target"` - `["npm"]` when unset, same
23
+ * default `publish` itself uses. */
24
+ publishTargets: RmanConfig.PublishTarget[];
25
+ /** Present only when `"docker"` is one of `publishTargets` and `publish.docker` is configured -
26
+ * the raw `.rmanrc` config, unresolved (no namespace prefixing - see `DockerPublishService`). */
27
+ docker?: RmanConfig.DockerPublishOptions;
21
28
  }
22
29
  /** `list`: every package in the repository (or, with `changed`/`changedSince`, only the ones
23
30
  * that have actually changed), each with its version, location, private flag, and change
@@ -9,14 +9,20 @@ export var ListService;
9
9
  async function getPackages(repository, options = {}) {
10
10
  const packages = filterPackages(repository.getPackages({ toposort: options.toposort }), options);
11
11
  const status = await repository.listStatus({ hash: options.changedSince });
12
- let items = packages.map(p => ({
13
- name: p.name,
14
- version: p.version,
15
- location: path.relative(repository.dirname, p.dirname) || '.',
16
- private: p.isPrivate,
17
- status: status[p.name],
18
- dependencies: [...p.dependencies],
19
- }));
12
+ let items = packages.map(p => {
13
+ const target = p.config.publish?.target;
14
+ const publishTargets = Array.isArray(target) ? target : target ? [target] : ['npm'];
15
+ return {
16
+ name: p.name,
17
+ version: p.version,
18
+ location: path.relative(repository.dirname, p.dirname) || '.',
19
+ private: p.isPrivate,
20
+ status: status[p.name],
21
+ dependencies: [...p.dependencies],
22
+ publishTargets: [...publishTargets],
23
+ docker: publishTargets.includes('docker') ? p.config.publish?.docker : undefined,
24
+ };
25
+ });
20
26
  if (options.changed || options.changedSince)
21
27
  items = items.filter(it => it.status !== 'clean');
22
28
  return items;
package/services.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { ChangelogService } from './services/changelog.service.js';
2
2
  export { CiService } from './services/ci.service.js';
3
3
  export { CleanService } from './services/clean.service.js';
4
+ export { DockerPublishService } from './services/docker-publish.service.js';
4
5
  export { ExecService } from './services/exec.service.js';
5
6
  export { ImportService } from './services/import.service.js';
6
7
  export { ListService } from './services/list.service.js';
package/services.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { ChangelogService } from './services/changelog.service.js';
2
2
  export { CiService } from './services/ci.service.js';
3
3
  export { CleanService } from './services/clean.service.js';
4
+ export { DockerPublishService } from './services/docker-publish.service.js';
4
5
  export { ExecService } from './services/exec.service.js';
5
6
  export { ImportService } from './services/import.service.js';
6
7
  export { ListService } from './services/list.service.js';