@celilo/cli 0.3.14 → 0.3.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -506,8 +506,14 @@ export const COMMANDS: CommandDef[] = [
506
506
  },
507
507
  {
508
508
  name: 'publish',
509
- description: 'Build and publish a module to the registry',
510
- args: [{ name: 'module-dir', description: 'Module directory', completion: 'directories' }],
509
+ description: 'Build and publish one or more modules to the registry',
510
+ args: [
511
+ {
512
+ name: 'module-dir...',
513
+ description: 'One or more module directories (shell-glob ./modules/* works)',
514
+ completion: 'directories',
515
+ },
516
+ ],
511
517
  flags: [
512
518
  { name: 'token', description: 'Publish token', takesValue: true },
513
519
  { name: 'registry', description: 'Registry URL', takesValue: true },
@@ -522,6 +528,11 @@ export const COMMANDS: CommandDef[] = [
522
528
  description: 'Bypass the clean-working-tree check (emergency publish)',
523
529
  takesValue: false,
524
530
  },
531
+ {
532
+ name: 'allow-stale',
533
+ description: 'Skip the manifest-vs-src stale-check (use sparingly)',
534
+ takesValue: false,
535
+ },
525
536
  ],
526
537
  },
527
538
  ],
@@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
15
  import { mkdir, rm, writeFile } from 'node:fs/promises';
16
16
  import { join } from 'node:path';
17
17
  import type { IndexEntry } from '../../registry/client';
18
- import { handleModulePublish, validateCapabilityVersions } from './module-publish';
18
+ import { handleModulePublish, resolveToken, validateCapabilityVersions } from './module-publish';
19
19
 
20
20
  const TEST_DIR = `/tmp/test-module-publish-${Date.now()}`;
21
21
 
@@ -37,14 +37,25 @@ describe('handleModulePublish — validation', () => {
37
37
  });
38
38
 
39
39
  test('missing token returns error', async () => {
40
+ // Isolate from the operator's real Celilo DB — otherwise the secret-store
41
+ // fallback in resolveToken() may read a real publish token and the test
42
+ // will skip the validation path it's trying to exercise.
40
43
  const origToken = process.env.CELILO_PUBLISH_TOKEN;
44
+ const origDbPath = process.env.CELILO_DB_PATH;
45
+ const origDataDir = process.env.CELILO_DATA_DIR;
41
46
  process.env.CELILO_PUBLISH_TOKEN = undefined;
47
+ process.env.CELILO_DB_PATH = `/tmp/test-celilo-no-token-${Date.now()}.db`;
48
+ process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-${Date.now()}`;
42
49
  try {
43
50
  const result = await handleModulePublish([TEST_DIR], {});
44
51
  expect(result.success).toBe(false);
45
52
  if (!result.success) expect(result.error).toContain('Publish token required');
46
53
  } finally {
47
54
  if (origToken !== undefined) process.env.CELILO_PUBLISH_TOKEN = origToken;
55
+ if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
56
+ else process.env.CELILO_DB_PATH = undefined;
57
+ if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
58
+ else process.env.CELILO_DATA_DIR = undefined;
48
59
  }
49
60
  });
50
61
 
@@ -233,3 +244,75 @@ describe('validateCapabilityVersions', () => {
233
244
  expect(errors).toHaveLength(2);
234
245
  });
235
246
  });
247
+
248
+ // ── Token resolution: --token → env → secret store ────────────────────────────
249
+
250
+ describe('resolveToken — precedence', () => {
251
+ test('--token flag wins over env var and secret store', async () => {
252
+ const origEnv = process.env.CELILO_PUBLISH_TOKEN;
253
+ const origDbPath = process.env.CELILO_DB_PATH;
254
+ process.env.CELILO_PUBLISH_TOKEN = 'env-token';
255
+ process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`;
256
+ try {
257
+ const t = await resolveToken('flag-token');
258
+ expect(t).toBe('flag-token');
259
+ } finally {
260
+ if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
261
+ else process.env.CELILO_PUBLISH_TOKEN = undefined;
262
+ if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
263
+ else process.env.CELILO_DB_PATH = undefined;
264
+ }
265
+ });
266
+
267
+ test('env var used when --token flag is empty', async () => {
268
+ const origEnv = process.env.CELILO_PUBLISH_TOKEN;
269
+ const origDbPath = process.env.CELILO_DB_PATH;
270
+ process.env.CELILO_PUBLISH_TOKEN = 'env-token';
271
+ process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-${Date.now()}.db`;
272
+ try {
273
+ const t = await resolveToken('');
274
+ expect(t).toBe('env-token');
275
+ } finally {
276
+ if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
277
+ else process.env.CELILO_PUBLISH_TOKEN = undefined;
278
+ if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
279
+ else process.env.CELILO_DB_PATH = undefined;
280
+ }
281
+ });
282
+
283
+ test('returns empty string when no source has a token', async () => {
284
+ // All three sources empty: secret-store fallback hits an isolated empty
285
+ // DB and returns null gracefully.
286
+ const origEnv = process.env.CELILO_PUBLISH_TOKEN;
287
+ const origDbPath = process.env.CELILO_DB_PATH;
288
+ const origDataDir = process.env.CELILO_DATA_DIR;
289
+ process.env.CELILO_PUBLISH_TOKEN = undefined;
290
+ process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-empty-${Date.now()}.db`;
291
+ process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-empty-${Date.now()}`;
292
+ try {
293
+ const t = await resolveToken('');
294
+ expect(t).toBe('');
295
+ } finally {
296
+ if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
297
+ if (origDbPath !== undefined) process.env.CELILO_DB_PATH = origDbPath;
298
+ else process.env.CELILO_DB_PATH = undefined;
299
+ if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
300
+ else process.env.CELILO_DATA_DIR = undefined;
301
+ }
302
+ });
303
+ });
304
+
305
+ // ── Multi-module argument handling ────────────────────────────────────────────
306
+
307
+ describe('handleModulePublish — multi-module', () => {
308
+ test('--revision combined with multiple module dirs is rejected', async () => {
309
+ const result = await handleModulePublish(['./a', './b'], {
310
+ token: 'tok',
311
+ revision: '1',
312
+ });
313
+ expect(result.success).toBe(false);
314
+ if (!result.success) {
315
+ expect(result.error).toContain('--revision cannot be combined with multiple module dirs');
316
+ }
317
+ });
318
+ });
@@ -1,12 +1,28 @@
1
1
  /**
2
- * Module publish command — build and publish a module to the registry.
2
+ * Module publish command — build and publish one or more modules to the registry.
3
3
  *
4
- * Usage: celilo module publish <module-dir> --token <token>
4
+ * Usage: celilo module publish <module-dir>... [--token <token>]
5
5
  * [--registry <url>] [--revision <n>]
6
6
  * [--message "release note"] [--allow-dirty]
7
+ * [--allow-stale]
7
8
  *
8
- * The --token flag (or CELILO_PUBLISH_TOKEN env var) must contain a valid
9
- * publish token configured on the registry server.
9
+ * Multiple module directories may be passed; each is built and published in
10
+ * order. A failure on any one stops the run (publishes are non-destructive,
11
+ * safe to retry after fixing the root cause).
12
+ *
13
+ * Token resolution order:
14
+ * 1. --token <t> flag
15
+ * 2. CELILO_PUBLISH_TOKEN env var
16
+ * 3. The locally-installed `celilo-registry` module's `publish_tokens`
17
+ * secret (first non-empty line). This is the path operators use after
18
+ * deploying their own registry — no env-var ritual required.
19
+ *
20
+ * Stale-check (D6):
21
+ * For each module, refuse to publish if commits touching the module dir
22
+ * landed AFTER the last commit that touched its manifest.yml. The fix is
23
+ * to bump manifest.yml#version (or just touch it, if the change is
24
+ * build-only — auto-revision handles the +N bump). --allow-stale
25
+ * overrides for one-time recovery from existing drift.
10
26
  *
11
27
  * Per CELILO_UPDATE D4 (strict-publish) the manifest's capability
12
28
  * versions are validated against `CAPABILITY_CONTRACT_VERSIONS` from
@@ -19,6 +35,7 @@
19
35
  * timestamp / CLI version / optional --message.
20
36
  */
21
37
 
38
+ import { spawnSync } from 'node:child_process';
22
39
  import { rm } from 'node:fs/promises';
23
40
  import { readFile } from 'node:fs/promises';
24
41
  import { tmpdir } from 'node:os';
@@ -37,7 +54,8 @@ import {
37
54
  readInstalledCliVersion,
38
55
  } from '../../module/packaging/release-metadata';
39
56
  import { RegistryClient } from '../../registry/client';
40
- import { getArg, getFlag, hasFlag } from '../parser';
57
+ import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
58
+ import { getFlag, hasFlag } from '../parser';
41
59
  import type { CommandResult } from '../types';
42
60
 
43
61
  interface ManifestCapabilityClaim {
@@ -96,39 +114,127 @@ export function validateCapabilityVersions(manifest: ManifestForPublish): string
96
114
  return errors;
97
115
  }
98
116
 
99
- export async function handleModulePublish(
100
- args: string[],
101
- flags: Record<string, string | boolean>,
102
- ): Promise<CommandResult> {
103
- const moduleDir = getArg(args, 0);
104
- if (!moduleDir) {
105
- return {
106
- success: false,
107
- error:
108
- 'Module directory required\n\nUsage: celilo module publish <module-dir> --token <token>',
109
- };
110
- }
117
+ /**
118
+ * Look up the SHA of the last commit touching the given pathspecs. Returns
119
+ * null on any git failure (no repo, never-committed file, etc.) — callers
120
+ * treat that as "can't determine, skip the check."
121
+ */
122
+ function lastCommitTouching(pathspec: string[]): string | null {
123
+ const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
124
+ stdio: ['ignore', 'pipe', 'ignore'],
125
+ encoding: 'utf-8',
126
+ });
127
+ if (r.status !== 0) return null;
128
+ const sha = r.stdout.trim();
129
+ return sha || null;
130
+ }
111
131
 
112
- const token = getFlag(flags, 'token', '') || process.env.CELILO_PUBLISH_TOKEN || '';
113
- if (!token) {
114
- return {
115
- success: false,
116
- error: 'Publish token required — pass --token <token> or set CELILO_PUBLISH_TOKEN',
117
- };
118
- }
132
+ function isAncestor(maybeAncestor: string, descendant: string): boolean {
133
+ const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
134
+ stdio: 'ignore',
135
+ });
136
+ return r.status === 0;
137
+ }
119
138
 
120
- const registryUrl = getFlag(flags, 'registry', '');
121
- const revisionOverride = getFlag(flags, 'revision', '');
122
- const message = getFlag(flags, 'message', '') || null;
123
- const allowDirty = hasFlag(flags, 'allow-dirty');
139
+ export interface StalenessIssue {
140
+ moduleDir: string;
141
+ lastSrcCommit: string;
142
+ lastManifestCommit: string;
143
+ }
124
144
 
125
- if (revisionOverride) {
126
- const parsed = Number(revisionOverride);
127
- if (!Number.isInteger(parsed) || parsed < 1) {
128
- return { success: false, error: '--revision must be a positive integer' };
129
- }
145
+ /**
146
+ * Detect "I edited module src but forgot to bump (or touch) manifest.yml."
147
+ *
148
+ * Returns null when the manifest is the most recently-touched file in the
149
+ * dir (or when neither side has any commit history — e.g. brand-new module
150
+ * not yet committed). Returns a StalenessIssue when src has commits AFTER
151
+ * the last manifest.yml change — the operator must bump the manifest
152
+ * (semver change → reset +N to 1) or just touch it (release-only change →
153
+ * auto-bump +N), then re-publish.
154
+ *
155
+ * Exported for testing.
156
+ */
157
+ export function checkModuleStale(moduleDir: string): StalenessIssue | null {
158
+ const manifestPath = join(moduleDir, 'manifest.yml');
159
+ const lastManifest = lastCommitTouching([manifestPath]);
160
+ // Excluding manifest.yml from the "src" pathspec is the whole point — we
161
+ // want to know if anything ELSE in the dir moved past it. node_modules and
162
+ // common build outputs are gitignored already, but be explicit defensively.
163
+ const lastSrc = lastCommitTouching([
164
+ moduleDir,
165
+ `:(exclude)${manifestPath}`,
166
+ `:(exclude)${moduleDir}/node_modules`,
167
+ `:(exclude)${moduleDir}/dist`,
168
+ ]);
169
+ if (!lastManifest || !lastSrc) return null;
170
+ if (lastSrc === lastManifest) return null; // same commit changed both
171
+ if (!isAncestor(lastManifest, lastSrc)) return null; // manifest moved last
172
+
173
+ return { moduleDir, lastSrcCommit: lastSrc, lastManifestCommit: lastManifest };
174
+ }
175
+
176
+ /**
177
+ * Resolve the publish token via three-tier fallback:
178
+ * 1. --token flag value passed directly (if non-empty)
179
+ * 2. CELILO_PUBLISH_TOKEN env var (if non-empty)
180
+ * 3. Celilo secret store: celilo-registry module's `publish_tokens` secret,
181
+ * first non-empty line. Lazy DB read — if Celilo isn't installed locally
182
+ * or the registry module isn't deployed, that's a graceful skip, not a
183
+ * crash.
184
+ *
185
+ * Exported for testing.
186
+ */
187
+ export async function resolveToken(flagValue: string): Promise<string> {
188
+ if (flagValue) return flagValue;
189
+ const envValue = process.env.CELILO_PUBLISH_TOKEN ?? '';
190
+ if (envValue) return envValue;
191
+
192
+ // Secret-store fallback. Wrap in try/catch — if the local Celilo install
193
+ // isn't initialized (no DB, no master key), we just return empty and let
194
+ // the caller report "no token found" with the full guidance.
195
+ try {
196
+ const dataManager = new CrossModuleDataManager();
197
+ await dataManager.initialize();
198
+ const raw = dataManager.getSecret('celilo-registry', 'publish_tokens');
199
+ if (!raw) return '';
200
+ const firstToken = raw
201
+ .split('\n')
202
+ .map((line) => line.trim())
203
+ .find((line) => line.length > 0);
204
+ return firstToken ?? '';
205
+ } catch {
206
+ return '';
130
207
  }
208
+ }
209
+
210
+ interface ResolvedOpts {
211
+ token: string;
212
+ registryUrl: string;
213
+ revisionOverride: number | null;
214
+ message: string | null;
215
+ allowDirty: boolean;
216
+ allowStale: boolean;
217
+ }
131
218
 
219
+ interface PerModuleOutcome {
220
+ moduleDir: string;
221
+ status: 'published' | 'skipped' | 'failed';
222
+ message: string;
223
+ /** Set when published — for the multi-publish summary. */
224
+ publishedAs?: string;
225
+ }
226
+
227
+ /**
228
+ * Publish a single module. Errors return outcome.status='failed' rather than
229
+ * throwing — the caller orchestrates the multi-module summary and decides
230
+ * whether to abort the run.
231
+ *
232
+ * Exported for testing.
233
+ */
234
+ export async function publishOneModule(
235
+ moduleDir: string,
236
+ opts: ResolvedOpts,
237
+ ): Promise<PerModuleOutcome> {
132
238
  const resolvedDir = resolve(moduleDir);
133
239
 
134
240
  // Read manifest
@@ -137,10 +243,18 @@ export async function handleModulePublish(
137
243
  const manifestRaw = await readFile(join(resolvedDir, 'manifest.yml'), 'utf-8');
138
244
  manifest = parseYaml(manifestRaw) as ManifestForPublish;
139
245
  if (!manifest.id || !manifest.version) {
140
- return { success: false, error: 'manifest.yml missing id or version' };
246
+ return {
247
+ moduleDir,
248
+ status: 'failed',
249
+ message: `${moduleDir}: manifest.yml missing id or version`,
250
+ };
141
251
  }
142
252
  } catch {
143
- return { success: false, error: `Could not read manifest.yml in ${resolvedDir}` };
253
+ return {
254
+ moduleDir,
255
+ status: 'failed',
256
+ message: `${moduleDir}: Could not read manifest.yml in ${resolvedDir}`,
257
+ };
144
258
  }
145
259
  const name = manifest.id;
146
260
  const baseVersion = manifest.version;
@@ -149,32 +263,55 @@ export async function handleModulePublish(
149
263
  const capErrors = validateCapabilityVersions(manifest);
150
264
  if (capErrors.length > 0) {
151
265
  return {
152
- success: false,
153
- error: [
154
- `Capability version validation failed for ${name}@${baseVersion}:`,
266
+ moduleDir,
267
+ status: 'failed',
268
+ message: [
269
+ `${moduleDir}: Capability version validation failed for ${name}@${baseVersion}:`,
155
270
  ...capErrors.map((e) => ` • ${e}`),
156
271
  ].join('\n'),
157
272
  };
158
273
  }
159
274
 
275
+ // Stale-check: src commits past the last manifest.yml commit. Only fires
276
+ // when the dir is in git history; brand-new uncommitted modules pass.
277
+ if (!opts.allowStale) {
278
+ const stale = checkModuleStale(resolvedDir);
279
+ if (stale) {
280
+ return {
281
+ moduleDir,
282
+ status: 'failed',
283
+ message: [
284
+ `${moduleDir}: Stale-version drift for ${name}@${baseVersion} —`,
285
+ ` src commit: ${stale.lastSrcCommit.slice(0, 12)}`,
286
+ ` manifest.yml commit: ${stale.lastManifestCommit.slice(0, 12)}`,
287
+ ` Files in ${moduleDir} changed after manifest.yml. Either bump`,
288
+ ' manifest.yml#version (semver change), or touch it (release-only',
289
+ ' change — auto-revision will pick the next +N). Then commit and retry.',
290
+ ' --allow-stale skips this check (use sparingly).',
291
+ ].join('\n'),
292
+ };
293
+ }
294
+ }
295
+
160
296
  // Collect git state. Refuse a dirty tree unless --allow-dirty.
161
297
  const gitInfo = collectGitInfo(resolvedDir, makeRealGitRunner());
162
- if (gitInfo.dirty && !allowDirty) {
298
+ if (gitInfo.dirty && !opts.allowDirty) {
163
299
  return {
164
- success: false,
165
- error: [
166
- `Working tree at ${resolvedDir} has uncommitted changes.`,
167
- 'Refusing to publish commit (or stash) your changes, or pass --allow-dirty to override.',
300
+ moduleDir,
301
+ status: 'failed',
302
+ message: [
303
+ `${moduleDir}: Working tree at ${resolvedDir} has uncommitted changes.`,
304
+ ' Refusing to publish — commit (or stash) your changes, or pass --allow-dirty to override.',
168
305
  ].join('\n'),
169
306
  };
170
307
  }
171
308
 
172
- // Determine package revision: --revision flag, or query the registry for next rev
309
+ // Determine package revision: --revision flag, or query the registry for next rev.
173
310
  let revision: number;
174
- if (revisionOverride) {
175
- revision = Number(revisionOverride);
311
+ if (opts.revisionOverride !== null) {
312
+ revision = opts.revisionOverride;
176
313
  } else {
177
- const client = new RegistryClient(registryUrl || undefined);
314
+ const client = new RegistryClient(opts.registryUrl || undefined);
178
315
  try {
179
316
  const entries = await client.getIndex(name);
180
317
  const existingRevs = entries
@@ -190,13 +327,34 @@ export async function handleModulePublish(
190
327
 
191
328
  const version = `${baseVersion}+${revision}`;
192
329
 
330
+ // Skip-if-explicit-version-already-published: only relevant when the user
331
+ // forced a revision number. Auto-revision always picks the next available
332
+ // slot, so there's no skip case.
333
+ if (opts.revisionOverride !== null) {
334
+ const client = new RegistryClient(opts.registryUrl || undefined);
335
+ try {
336
+ const entries = await client.getIndex(name);
337
+ const alreadyPublished = entries.some((e) => e.vers === version);
338
+ if (alreadyPublished) {
339
+ return {
340
+ moduleDir,
341
+ status: 'skipped',
342
+ message: `${name}@${version} already on registry — skipping.`,
343
+ };
344
+ }
345
+ } catch {
346
+ // Registry unreachable — let publish() fail at upload time with a
347
+ // clearer error than "couldn't list index."
348
+ }
349
+ }
350
+
193
351
  // Assemble release metadata for the .netapp.
194
352
  const releaseMetadata = buildReleaseMetadata({
195
353
  moduleId: name,
196
354
  version,
197
355
  git: gitInfo,
198
356
  cliVersion: readInstalledCliVersion(),
199
- message,
357
+ message: opts.message,
200
358
  });
201
359
 
202
360
  // Build the .netapp into a temp dir
@@ -209,26 +367,148 @@ export async function handleModulePublish(
209
367
  releaseMetadata,
210
368
  });
211
369
  if (!buildResult.success || !buildResult.packagePath) {
212
- return { success: false, error: buildResult.error ?? 'Build failed' };
370
+ return {
371
+ moduleDir,
372
+ status: 'failed',
373
+ message: `${moduleDir}: ${buildResult.error ?? 'Build failed'}`,
374
+ };
213
375
  }
214
376
 
215
377
  // Publish
216
- const client = new RegistryClient(registryUrl || undefined);
378
+ const client = new RegistryClient(opts.registryUrl || undefined);
217
379
  try {
218
380
  console.log(`Publishing ${name}@${version} to ${client.baseUrl}...`);
219
- await client.publish({ name, version, netappPath: buildResult.packagePath, token });
381
+ await client.publish({ name, version, netappPath: buildResult.packagePath, token: opts.token });
220
382
  } catch (err) {
221
383
  return {
222
- success: false,
223
- error: `Publish failed: ${err instanceof Error ? err.message : String(err)}`,
384
+ moduleDir,
385
+ status: 'failed',
386
+ message: `${moduleDir}: Publish failed: ${err instanceof Error ? err.message : String(err)}`,
224
387
  };
225
388
  } finally {
226
389
  await rm(tmpPath, { force: true });
227
390
  }
228
391
 
392
+ return {
393
+ moduleDir,
394
+ status: 'published',
395
+ message: `Published ${name}@${version}${opts.message ? ` — "${opts.message}"` : ''}`,
396
+ publishedAs: `${name}@${version}`,
397
+ };
398
+ }
399
+
400
+ export async function handleModulePublish(
401
+ args: string[],
402
+ flags: Record<string, string | boolean>,
403
+ ): Promise<CommandResult> {
404
+ if (args.length === 0) {
405
+ return {
406
+ success: false,
407
+ error:
408
+ 'Module directory required\n\nUsage: celilo module publish <module-dir>... [--token <token>]',
409
+ };
410
+ }
411
+
412
+ // Validate --revision early so multi-module runs fail fast.
413
+ const revisionFlag = getFlag(flags, 'revision', '');
414
+ let revisionOverride: number | null = null;
415
+ if (revisionFlag) {
416
+ const parsed = Number(revisionFlag);
417
+ if (!Number.isInteger(parsed) || parsed < 1) {
418
+ return { success: false, error: '--revision must be a positive integer' };
419
+ }
420
+ revisionOverride = parsed;
421
+ }
422
+ if (revisionOverride !== null && args.length > 1) {
423
+ return {
424
+ success: false,
425
+ error:
426
+ '--revision cannot be combined with multiple module dirs (each module has its own revision sequence)',
427
+ };
428
+ }
429
+
430
+ const token = await resolveToken(getFlag(flags, 'token', ''));
431
+ if (!token) {
432
+ return {
433
+ success: false,
434
+ error: [
435
+ 'Publish token required. Resolution order:',
436
+ ' 1. --token <token> flag',
437
+ ' 2. CELILO_PUBLISH_TOKEN env var',
438
+ " 3. The celilo-registry module's `publish_tokens` secret",
439
+ ' (set automatically when you deploy celilo-registry locally)',
440
+ ].join('\n'),
441
+ };
442
+ }
443
+
444
+ const opts: ResolvedOpts = {
445
+ token,
446
+ registryUrl: getFlag(flags, 'registry', ''),
447
+ revisionOverride,
448
+ message: getFlag(flags, 'message', '') || null,
449
+ allowDirty: hasFlag(flags, 'allow-dirty'),
450
+ allowStale: hasFlag(flags, 'allow-stale'),
451
+ };
452
+
453
+ const outcomes: PerModuleOutcome[] = [];
454
+ for (const moduleDir of args) {
455
+ const outcome = await publishOneModule(moduleDir, opts);
456
+ outcomes.push(outcome);
457
+ if (outcome.status === 'failed') {
458
+ // Print summary so far, then bail. Publishes are non-destructive so
459
+ // re-running after fixing the underlying issue picks up where we left
460
+ // off (already-published modules are skipped at the registry level
461
+ // when --revision is explicit, and auto-revision just picks the next
462
+ // slot — nothing duplicates).
463
+ printSummary(outcomes);
464
+ return {
465
+ success: false,
466
+ error: outcome.message,
467
+ };
468
+ }
469
+ console.log(outcome.message);
470
+ }
471
+
472
+ printSummary(outcomes);
473
+
474
+ if (args.length === 1) {
475
+ // Backward-compat single-module shape — keep the existing message/data
476
+ // contract for callers (and tests) that depend on it. By this point no
477
+ // outcome is 'failed' (we'd have early-returned above), so success is
478
+ // unconditional.
479
+ const only = outcomes[0];
480
+ return {
481
+ success: true,
482
+ message: only.message,
483
+ data: only.publishedAs ? { publishedAs: only.publishedAs } : undefined,
484
+ };
485
+ }
486
+
229
487
  return {
230
488
  success: true,
231
- message: `Published ${name}@${version}${message ? ` "${message}"` : ''}`,
232
- data: { name, version, releaseMetadata },
489
+ message: `Published ${outcomes.filter((o) => o.status === 'published').length} module(s).`,
233
490
  };
234
491
  }
492
+
493
+ function printSummary(outcomes: PerModuleOutcome[]): void {
494
+ if (outcomes.length <= 1) return; // single-module mode already printed its line
495
+ const published = outcomes.filter((o) => o.status === 'published');
496
+ const skipped = outcomes.filter((o) => o.status === 'skipped');
497
+ const failed = outcomes.filter((o) => o.status === 'failed');
498
+
499
+ console.log('\n──────────────────────────────────────────────');
500
+ console.log(' Module publish summary');
501
+ console.log('──────────────────────────────────────────────');
502
+ if (published.length > 0) {
503
+ console.log('Published:');
504
+ for (const o of published) console.log(` ✓ ${o.publishedAs}`);
505
+ }
506
+ if (skipped.length > 0) {
507
+ console.log('Skipped:');
508
+ for (const o of skipped) console.log(` - ${o.message}`);
509
+ }
510
+ if (failed.length > 0) {
511
+ console.log('Failed:');
512
+ for (const o of failed) console.log(` ✗ ${o.message}`);
513
+ }
514
+ }
package/src/cli/index.ts CHANGED
@@ -368,17 +368,21 @@ Registry:
368
368
  --registry <url> Use a custom registry
369
369
  --limit <n> Max results (default: 25)
370
370
 
371
- publish <module-dir> Build and publish a module to the registry
371
+ publish <module-dir>... Build and publish one or more modules to the registry
372
372
  Options:
373
- --token <token> Publish token (or set CELILO_PUBLISH_TOKEN env var)
373
+ --token <token> Publish token. Falls back to CELILO_PUBLISH_TOKEN env var, then celilo-registry's publish_tokens secret.
374
374
  --registry <url> Use a custom registry
375
- --revision <n> Force package revision number (default: auto)
375
+ --revision <n> Force package revision number (default: auto). Not allowed with multiple module dirs.
376
+ --message <text> Release note recorded in the .netapp's release.json
377
+ --allow-dirty Permit publishing from a dirty git tree
378
+ --allow-stale Skip the manifest-vs-src stale-check. Use sparingly.
376
379
 
377
380
  Examples:
378
381
  celilo module install caddy
379
382
  celilo module install namecheap --registry https://my-registry.example.com/registry
380
383
  celilo module search dns
381
384
  celilo module publish ./modules/caddy --token mytoken
385
+ celilo module publish ./modules/* # publish every module in a dir
382
386
  celilo module import ./modules/homebridge
383
387
  celilo module import homebridge.netapp
384
388
  celilo module import /abs/path/to/module --target /custom/location