@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
|
@@ -506,8 +506,14 @@ export const COMMANDS: CommandDef[] = [
|
|
|
506
506
|
},
|
|
507
507
|
{
|
|
508
508
|
name: 'publish',
|
|
509
|
-
description: 'Build and publish
|
|
510
|
-
args: [
|
|
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
|
|
2
|
+
* Module publish command — build and publish one or more modules to the registry.
|
|
3
3
|
*
|
|
4
|
-
* Usage: celilo module publish <module-dir
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
export interface StalenessIssue {
|
|
140
|
+
moduleDir: string;
|
|
141
|
+
lastSrcCommit: string;
|
|
142
|
+
lastManifestCommit: string;
|
|
143
|
+
}
|
|
124
144
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 {
|
|
246
|
+
return {
|
|
247
|
+
moduleDir,
|
|
248
|
+
status: 'failed',
|
|
249
|
+
message: `${moduleDir}: manifest.yml missing id or version`,
|
|
250
|
+
};
|
|
141
251
|
}
|
|
142
252
|
} catch {
|
|
143
|
-
return {
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
223
|
-
|
|
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 ${
|
|
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
|
|
371
|
+
publish <module-dir>... Build and publish one or more modules to the registry
|
|
372
372
|
Options:
|
|
373
|
-
--token <token> Publish token
|
|
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
|