@detergent-software/atk 8.9.0-dev.2 → 8.9.0-dev.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,802 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { loadAllAdapters, resolveInstalledPaths } from './adapter.js';
4
+ import { isApiError } from './api-client.js';
5
+ import { getCredential } from './auth.js';
6
+ import { hashFile } from './checksum.js';
7
+ import { errorGuidance, errorMessage } from './errors.js';
8
+ import { detectCompatibleTools, getSignedInUserName } from './init.js';
9
+ import { addAssetToLockfile, findInstalledAsset, readLockfile, withLockfileLock, writeLockfile } from './lockfile.js';
10
+ import { diffManifests, fetchExistingManifest } from './manifest-diff.js';
11
+ import { parseOrgFromName } from './org.js';
12
+ import { findProjectRoot } from './paths.js';
13
+ import { buildPublishPlan, buildStagingArea, checkRegistryVersion, detectAssetFromPath, detectPublishType, executePublish, findSiblingDependencies, loadPublishBatch, loadPublishEntries, mapPublishError, missingDependencies, resolvePlanDependencies, unknownToolsError, updateManifestField, updateManifestVersion, validatePublishTarget, } from './publisher.js';
14
+ import { clearCache, fetchAssetManifest, fetchRegistry, findAsset, findBundle } from './registry.js';
15
+ import { parseReviewersInput, validateGithubUsername } from './reviewers.js';
16
+ import { stageInstalledFiles } from './staging.js';
17
+ import { resolveTools } from './tool-resolver.js';
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+ export function createInitialState(flags) {
22
+ // Non-interactive when --yes, --dry-run, --json, or stdin is not a TTY (raw mode
23
+ // unsupported). Defaults isTTY to true when the flag is omitted (call site responsible
24
+ // for threading useStdin().isRawModeSupported in). --dry-run and --json force
25
+ // non-interactive regardless of TTY — JSON output cannot interleave with prompts.
26
+ const isTTY = flags.isTTY ?? true;
27
+ const nonInteractive = Boolean(flags.yes) || flags.dryRun || Boolean(flags.json) || !isTTY;
28
+ const flagError = batchFlagError(flags);
29
+ return {
30
+ authorValue: '',
31
+ bumpSelection: '',
32
+ confirmValue: '',
33
+ descriptionValue: '',
34
+ entries: [],
35
+ errorMessage: flagError ?? '',
36
+ flags,
37
+ isRawPublish: false,
38
+ isUpdate: false,
39
+ manifestDiff: undefined,
40
+ metadataSubPhase: 'description',
41
+ missingDependencies: [],
42
+ nonInteractive,
43
+ orgValue: '',
44
+ phase: flagError
45
+ ? 'error'
46
+ : flags.update
47
+ ? 'resolving-update'
48
+ : flags.fromInstalled
49
+ ? 'resolving-installed'
50
+ : 'detecting',
51
+ plan: undefined,
52
+ progressMessage: '',
53
+ promptTagsValue: '',
54
+ publishConfirmValue: '',
55
+ publishResult: undefined,
56
+ rawDetection: null,
57
+ resolvedPath: flags.update ? '' : flags.fromInstalled ? '' : resolve(flags.rawPath),
58
+ resolvedVersion: '',
59
+ reviewerValue: '',
60
+ selectedPromptTools: [],
61
+ suggestedEntries: [],
62
+ tagsValue: '',
63
+ target: undefined,
64
+ token: undefined,
65
+ toolAdapters: [],
66
+ toolCursorIndex: 0,
67
+ toolsDetected: [],
68
+ validation: undefined,
69
+ versionCheck: undefined,
70
+ withConfirmValue: '',
71
+ };
72
+ }
73
+ /** Several folders published together (`atk publish a b c`) in one pull request; one folder named twice is one. */
74
+ export function isBatch(flags) {
75
+ return new Set(flags.paths?.map((path) => resolve(path))).size > 1;
76
+ }
77
+ /**
78
+ * Run the step for the state's phase, if it has one: the work the phase waits on,
79
+ * reported through `ctx.dispatch`, which reaches the runner only until the run is
80
+ * cancelled. An error the step throws is dispatched too, so this never rejects.
81
+ * Prompt and terminal phases have no step.
82
+ */
83
+ export async function runPublishStep(state, ctx) {
84
+ const step = stepFor(state);
85
+ if (!step)
86
+ return;
87
+ const guarded = {
88
+ ...ctx,
89
+ dispatch: (action) => {
90
+ if (!ctx.cancelled())
91
+ ctx.dispatch(action);
92
+ },
93
+ };
94
+ try {
95
+ await step.run(state, guarded);
96
+ }
97
+ catch (err) {
98
+ if (ctx.cancelled())
99
+ return;
100
+ const action = step.fail
101
+ ? await step.fail(err, state, guarded).catch(() => toErrorAction(err))
102
+ : toErrorAction(err);
103
+ if (action)
104
+ guarded.dispatch(action);
105
+ }
106
+ }
107
+ /** The comma-separated values in a flag or prompt answer, trimmed, without empty ones. */
108
+ export function splitList(value) {
109
+ return (value ?? '')
110
+ .split(',')
111
+ .map((item) => item.trim())
112
+ .filter(Boolean);
113
+ }
114
+ /** Why the flags can't publish a batch: one installed asset, or one asset's metadata. Undefined otherwise. */
115
+ function batchFlagError(flags) {
116
+ if (!isBatch(flags))
117
+ return undefined;
118
+ if (flags.update || flags.fromInstalled) {
119
+ return `${flags.update ? '--update' : '--from-installed'} publishes one installed asset; leave it out to publish several folders.`;
120
+ }
121
+ const perAsset = [
122
+ ['--description', flags.descriptionFlag],
123
+ ['--tags', flags.tagsFlag],
124
+ ['--tools', flags.toolsFlag],
125
+ ].flatMap(([flag, value]) => (value === undefined ? [] : [flag]));
126
+ if (perAsset.length > 0) {
127
+ return `${perAsset.join(', ')} ${perAsset.length === 1 ? 'sets' : 'set'} one asset's metadata; put it in each folder's manifest.json to publish several folders.`;
128
+ }
129
+ return undefined;
130
+ }
131
+ /** In a batch, what the API calls `Root asset`, as the folder the user named. */
132
+ function batchRootLabel(state) {
133
+ return isBatch(state.flags) ? state.flags.rawPath : undefined;
134
+ }
135
+ /** What the API calls `dependencies[i]`, as the folder the user named. */
136
+ function entryLabels(state) {
137
+ return state.entries.map((entry) => entry.label);
138
+ }
139
+ /** The installed asset `--from-installed` or `--update` names, with the tool's adapter; an error when it isn't installed. */
140
+ async function findInstalledSource(state, ctx) {
141
+ const assetName = state.flags.rawPath;
142
+ const { name, org } = parseOrgFromName(assetName);
143
+ const projectRoot = findProjectRoot(state.flags.projectRoot);
144
+ const tools = await resolveTools(state.flags.tool);
145
+ const adapter = tools[0].adapter;
146
+ const lockfile = await readLockfile(projectRoot);
147
+ const installed = findInstalledAsset(lockfile, name, undefined, org);
148
+ if (!installed) {
149
+ ctx.dispatch({ message: `Asset '${assetName}' is not installed.`, type: 'ERROR' });
150
+ return undefined;
151
+ }
152
+ return { adapter, installed, org, projectRoot };
153
+ }
154
+ /** Run the writes in order until one throws: its index and error, or undefined when all succeed. */
155
+ async function firstFailure(edits) {
156
+ for (const [index, edit] of edits.entries()) {
157
+ try {
158
+ await edit.write();
159
+ }
160
+ catch (error) {
161
+ return { error, index };
162
+ }
163
+ }
164
+ return undefined;
165
+ }
166
+ function stepFor(state) {
167
+ switch (state.phase) {
168
+ case 'building-plan':
169
+ return buildingPlan;
170
+ case 'checking-entries':
171
+ return checkingEntries;
172
+ case 'checking-registry':
173
+ return checkingRegistry;
174
+ case 'detecting':
175
+ return detecting;
176
+ case 'gathering-metadata':
177
+ return state.metadataSubPhase === 'building-staging' ? buildingStaging : undefined;
178
+ case 'installing':
179
+ return installing;
180
+ case 'loading-tools':
181
+ return loadingTools;
182
+ case 'publishing':
183
+ return publishing;
184
+ case 'resolving-installed':
185
+ return resolvingInstalled;
186
+ case 'resolving-update':
187
+ return resolvingUpdate;
188
+ case 'validating':
189
+ return validating;
190
+ default:
191
+ return undefined;
192
+ }
193
+ }
194
+ /**
195
+ * Folders next to the root asset that could fix the plan's error: an interactive
196
+ * run, a `422` naming root dependencies that aren't in the registry (or have no
197
+ * matching version), and a sibling folder whose manifest has the same type and
198
+ * name that isn't an entry yet. Undefined when there is nothing to offer.
199
+ * `--update` and `--from-installed` roots live in a temp folder, so they never get offers.
200
+ */
201
+ async function suggestSiblingEntries(state, err) {
202
+ const { target } = state;
203
+ if (state.nonInteractive ||
204
+ state.flags.fromInstalled ||
205
+ state.flags.update ||
206
+ target?.type !== 'asset' ||
207
+ !(isApiError(err, 'dependency_not_found') || isApiError(err, 'dependency_version_unsatisfied'))) {
208
+ return undefined;
209
+ }
210
+ const included = (d) => state.entries.some((e) => e.target.assetType === d.type && e.target.data.name === d.name);
211
+ const missing = missingDependencies(target.data, err.issues).filter((d) => !included(d));
212
+ if (missing.length === 0)
213
+ return undefined;
214
+ const rootDir = state.rawDetection?.sourceDir ?? target.sourceDir;
215
+ const suggested = await findSiblingDependencies(rootDir, missing, state.entries.map((e) => e.target.sourceDir), target.data.org);
216
+ return suggested.length > 0 ? { missing, suggested } : undefined;
217
+ }
218
+ /**
219
+ * Build an ERROR dispatch action from a caught value, carrying the `code`,
220
+ * `hint` and `command` typed errors have so commands can render actionable
221
+ * recovery guidance.
222
+ */
223
+ function toErrorAction(err, override) {
224
+ return {
225
+ ...errorGuidance(err),
226
+ message: override ?? errorMessage(err),
227
+ type: 'ERROR',
228
+ };
229
+ }
230
+ /**
231
+ * Write what the run changed in memory to the manifests on disk: the root's bump and each
232
+ * entry's, and the tags and tools the prompts asked for. Only once the publish has opened its
233
+ * PR, so a cancelled or failed one leaves every manifest as it was. A file whose write fails
234
+ * takes none of its later edits and comes back as one warning naming the fields it lacks; the
235
+ * other files are still written.
236
+ */
237
+ async function writeManifestEdits(state) {
238
+ const target = state.target;
239
+ if (!target)
240
+ return [];
241
+ const files = new Map();
242
+ const edit = (dir, file, field, value, write) => {
243
+ const path = join(dir, file);
244
+ files.set(path, [...(files.get(path) ?? []), { field, value, write }]);
245
+ };
246
+ const version = (dir, file, value) => edit(dir, file, 'version', value, () => updateManifestVersion(dir, file, value));
247
+ const field = (name, value) => edit(target.sourceDir, 'manifest.json', name, value, () => updateManifestField(target.sourceDir, 'manifest.json', name, value));
248
+ if (state.versionCheck?.status === 'needs-bump' && state.resolvedVersion) {
249
+ version(target.sourceDir, target.type === 'asset' ? 'manifest.json' : 'bundle.json', state.resolvedVersion);
250
+ }
251
+ // Only an asset's manifest.json takes the prompted tags and tools, as the target (and so the plan) has them.
252
+ if (target.type === 'asset') {
253
+ const { tags, tools } = target.data;
254
+ if (state.promptTagsValue && tags && tags.length > 0) {
255
+ field('tags', tags);
256
+ }
257
+ if (state.selectedPromptTools.length > 0) {
258
+ field('tools', tools);
259
+ }
260
+ }
261
+ for (const entry of state.entries) {
262
+ if (entry.versionCheck?.status === 'needs-bump' && entry.resolvedVersion) {
263
+ version(entry.target.sourceDir, 'manifest.json', entry.resolvedVersion);
264
+ }
265
+ }
266
+ const unwritten = [];
267
+ for (const [path, edits] of files) {
268
+ const failed = await firstFailure(edits);
269
+ if (!failed)
270
+ continue;
271
+ const missing = edits.slice(failed.index);
272
+ // Only the version matters to the next publish: it asks for missing tags and tools again.
273
+ const again = missing.some((e) => e.field === 'version') ? ' before you publish it again' : '';
274
+ unwritten.push(`The PR is open, but ${path} wasn't updated (${errorMessage(failed.error)}): set ${missing.map((e) => `"${e.field}" to ${JSON.stringify(e.value)}`).join(', ')}${again}.`);
275
+ }
276
+ return unwritten;
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // Steps, one per phase that waits on work
280
+ // ---------------------------------------------------------------------------
281
+ const resolvingInstalled = {
282
+ async run(state, ctx) {
283
+ const source = await findInstalledSource(state, ctx);
284
+ if (!source)
285
+ return;
286
+ const { adapter, installed, projectRoot } = source;
287
+ // Copy the installed files into a temp directory, preserving their source paths
288
+ const tempDir = await stageInstalledFiles(installed, adapter, projectRoot);
289
+ ctx.stage(tempDir);
290
+ if (ctx.cancelled())
291
+ return;
292
+ // Generate manifest.json in the temp dir
293
+ const manifest = {
294
+ author: 'unknown',
295
+ description: `Published from installed asset ${installed.name}`,
296
+ entrypoint: installed.files[0]?.sourcePath ?? 'index.md',
297
+ name: installed.name,
298
+ ...(installed.org ? { org: installed.org } : {}),
299
+ type: installed.type,
300
+ version: installed.version,
301
+ };
302
+ await writeFile(join(tempDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
303
+ ctx.dispatch({ resolvedPath: tempDir, type: 'RESOLVED_INSTALLED' });
304
+ },
305
+ };
306
+ const resolvingUpdate = {
307
+ async run(state, ctx) {
308
+ const source = await findInstalledSource(state, ctx);
309
+ if (!source)
310
+ return;
311
+ const { adapter, installed, org, projectRoot } = source;
312
+ const assetName = state.flags.rawPath;
313
+ // Fetch the registry to find the latest version
314
+ const registry = await fetchRegistry({ force: state.flags.refresh });
315
+ if (ctx.cancelled())
316
+ return;
317
+ // Look up the asset in the registry: try findAsset first, then findBundle
318
+ const resolvedAsset = findAsset(registry, installed.name, installed.type, undefined, installed.org ?? org);
319
+ const resolvedBundle = resolvedAsset ? undefined : findBundle(registry, installed.name, installed.org ?? org);
320
+ if (!resolvedAsset && !resolvedBundle) {
321
+ ctx.dispatch({ message: `Asset '${assetName}' not found in the registry.`, type: 'ERROR' });
322
+ return;
323
+ }
324
+ const latestVersion = resolvedAsset ? resolvedAsset.version : resolvedBundle.version;
325
+ const assetType = resolvedAsset ? resolvedAsset.asset.type : 'bundle';
326
+ // Fetch the authoritative manifest (and optional README) from the registry
327
+ const fetched = await fetchAssetManifest({ name: installed.name, org: installed.org ?? org, type: assetType }, latestVersion);
328
+ if (ctx.cancelled())
329
+ return;
330
+ // Copy the user's locally modified files into a temp staging directory
331
+ const tempDir = await stageInstalledFiles(installed, adapter, projectRoot);
332
+ ctx.stage(tempDir);
333
+ if (ctx.cancelled())
334
+ return;
335
+ // Write the fetched manifest into the staging directory. It is the manifest as this
336
+ // CLI's schema parsed it: a field the schema does not know (from a newer publisher)
337
+ // is dropped here and so is missing from the re-published version.
338
+ await writeFile(join(tempDir, fetched.manifestFileName), fetched.manifestContent, 'utf-8');
339
+ // Write README if present
340
+ if (fetched.readmeContent) {
341
+ await writeFile(join(tempDir, 'README.md'), fetched.readmeContent, 'utf-8');
342
+ }
343
+ ctx.dispatch({ resolvedPath: tempDir, type: 'RESOLVED_UPDATE' });
344
+ },
345
+ };
346
+ const detecting = {
347
+ async run(state, ctx) {
348
+ // A batch reads every folder now; any that is missing, a bundle or not an asset folder
349
+ // fails here, all of them together, before any prompt.
350
+ if (isBatch(state.flags)) {
351
+ const { entries, root } = await loadPublishBatch(state.flags.paths ?? [], state.flags.withPaths);
352
+ if (ctx.cancelled())
353
+ return;
354
+ ctx.dispatch({ entries, type: 'ENTRIES_LOADED' });
355
+ ctx.dispatch({ target: root.target, type: 'DETECT_DONE' });
356
+ return;
357
+ }
358
+ // A --with folder that is missing, a bundle, the root or a repeat fails here, before any
359
+ // prompt; its validation and version check come in checking-entries.
360
+ if (state.flags.withPaths?.length) {
361
+ const entries = await loadPublishEntries(state.flags.withPaths, state.resolvedPath);
362
+ if (ctx.cancelled())
363
+ return;
364
+ ctx.dispatch({ entries, type: 'ENTRIES_LOADED' });
365
+ }
366
+ // First, try manifest-based detection (existing flow)
367
+ try {
368
+ const detected = await detectPublishType(state.resolvedPath);
369
+ if (ctx.cancelled())
370
+ return;
371
+ ctx.dispatch({ target: detected, type: 'DETECT_DONE' });
372
+ return;
373
+ }
374
+ catch (manifestError) {
375
+ // Only attempt raw detection if the error is specifically about missing manifest/bundle
376
+ const manifestMsg = errorMessage(manifestError);
377
+ if (!manifestMsg.includes('No manifest.json or bundle.json found')) {
378
+ // This is a different error (e.g., invalid JSON in manifest) — surface it directly
379
+ ctx.dispatch({ message: manifestMsg, type: 'ERROR' });
380
+ return;
381
+ }
382
+ }
383
+ // Manifest not found — attempt raw asset detection
384
+ const resolvedTools = await resolveTools(state.flags.tool);
385
+ const adapter = resolvedTools[0].adapter;
386
+ if (ctx.cancelled())
387
+ return;
388
+ const projectRoot = findProjectRoot(state.flags.projectRoot);
389
+ const detection = await detectAssetFromPath(state.resolvedPath, adapter, projectRoot);
390
+ if (ctx.cancelled())
391
+ return;
392
+ // Pre-fill metadata
393
+ const author = (await getSignedInUserName()) ?? '';
394
+ if (ctx.cancelled())
395
+ return;
396
+ const lockfile = await readLockfile(projectRoot);
397
+ if (ctx.cancelled())
398
+ return;
399
+ const tools = await detectCompatibleTools(detection.assetType);
400
+ if (ctx.cancelled())
401
+ return;
402
+ // Non-interactive mode (raw publish — no manifest exists yet, so the only
403
+ // sources for description are --description flag or exit-1; tags come from
404
+ // --tags or default to empty). Tool detection runs for both modes above.
405
+ if (!state.nonInteractive) {
406
+ // Interactive mode — gather metadata from the user
407
+ ctx.dispatch({
408
+ authorValue: author,
409
+ orgValue: lockfile.org ?? '',
410
+ rawDetection: detection,
411
+ toolsDetected: tools,
412
+ type: 'RAW_DETECT_DONE',
413
+ });
414
+ return;
415
+ }
416
+ if (!state.flags.descriptionFlag) {
417
+ ctx.dispatch({
418
+ message: 'description required: provide --description or include in manifest.json',
419
+ type: 'ERROR',
420
+ });
421
+ return;
422
+ }
423
+ // Tools resolution: --tools flag -> detected (already populated above)
424
+ let toolsList;
425
+ if (state.flags.toolsFlag) {
426
+ toolsList = splitList(state.flags.toolsFlag);
427
+ }
428
+ else if (tools.length > 0) {
429
+ toolsList = tools;
430
+ }
431
+ else {
432
+ ctx.dispatch({
433
+ message: `--tools <comma-separated> required: no tools detected for type ${detection.assetType}`,
434
+ type: 'ERROR',
435
+ });
436
+ return;
437
+ }
438
+ const stagingDir = await buildStagingArea(detection, {
439
+ author,
440
+ description: state.flags.descriptionFlag,
441
+ ...(lockfile.org ? { org: lockfile.org } : {}),
442
+ tags: splitList(state.flags.tagsFlag),
443
+ tools: toolsList,
444
+ });
445
+ ctx.stage(stagingDir);
446
+ if (ctx.cancelled())
447
+ return;
448
+ const detected = await detectPublishType(stagingDir);
449
+ if (ctx.cancelled())
450
+ return;
451
+ ctx.dispatch({ rawDetection: detection, target: detected, type: 'RAW_DETECT_DONE_NON_INTERACTIVE' });
452
+ },
453
+ };
454
+ /** The `building-staging` sub-phase of `gathering-metadata`: the manifest from the prompts' answers. */
455
+ const buildingStaging = {
456
+ async run(state, ctx) {
457
+ const stagingDir = await buildStagingArea(state.rawDetection, {
458
+ author: state.authorValue,
459
+ description: state.descriptionValue,
460
+ org: state.orgValue || undefined,
461
+ tags: splitList(state.tagsValue),
462
+ tools: state.toolsDetected,
463
+ });
464
+ ctx.stage(stagingDir);
465
+ if (ctx.cancelled())
466
+ return;
467
+ // Re-detect using the staging area (now has manifest.json)
468
+ const detectedTarget = await detectPublishType(stagingDir);
469
+ if (ctx.cancelled())
470
+ return;
471
+ ctx.dispatch({ target: detectedTarget, type: 'STAGING_DONE' });
472
+ },
473
+ };
474
+ const validating = {
475
+ async run(state, ctx) {
476
+ const target = state.target;
477
+ if (!target)
478
+ return;
479
+ let lockfileOrg;
480
+ let lockfileOrgReviewers;
481
+ try {
482
+ const projectRoot = findProjectRoot(state.flags.projectRoot);
483
+ const lockfile = await readLockfile(projectRoot);
484
+ lockfileOrg = lockfile.org;
485
+ lockfileOrgReviewers = lockfile.orgReviewers;
486
+ }
487
+ catch {
488
+ // No project root / lockfile — treat as no lockfile org / reviewers
489
+ }
490
+ if (ctx.cancelled())
491
+ return;
492
+ // Bundles need the registry so their asset refs can be resolved during
493
+ // validation — an unresolvable ref must fail here, not at install time.
494
+ let registry;
495
+ if (target.type === 'bundle') {
496
+ registry = await fetchRegistry({ force: state.flags.refresh });
497
+ if (ctx.cancelled())
498
+ return;
499
+ }
500
+ const result = await validatePublishTarget(target, {
501
+ force: state.flags.force,
502
+ lockfileOrg,
503
+ registry,
504
+ });
505
+ if (ctx.cancelled())
506
+ return;
507
+ // A batch checks every asset before stopping, so one run lists every folder's problems.
508
+ if (isBatch(state.flags)) {
509
+ const entries = [];
510
+ for (const entry of state.entries) {
511
+ const validation = await validatePublishTarget(entry.target, { force: state.flags.force, lockfileOrg });
512
+ if (ctx.cancelled())
513
+ return;
514
+ entries.push({ ...entry, validation });
515
+ }
516
+ const failed = [{ label: state.flags.rawPath, validation: result }, ...entries].filter((f) => f.validation && !f.validation.valid);
517
+ if (failed.length > 0) {
518
+ const lines = failed.flatMap((f) => [
519
+ ` ${f.label}:`,
520
+ ...(f.validation?.errors ?? []).map((e) => ` - ${e}`),
521
+ ]);
522
+ ctx.dispatch({ message: `Validation failed:\n${lines.join('\n')}`, type: 'ERROR' });
523
+ return;
524
+ }
525
+ // checking-entries keeps these instead of validating each entry again.
526
+ ctx.dispatch({ entries, type: 'ENTRIES_LOADED' });
527
+ }
528
+ ctx.dispatch({ lockfileOrgReviewers, type: 'VALIDATE_DONE', validation: result });
529
+ },
530
+ };
531
+ /**
532
+ * In interactive mode, loads adapters for the multi-select prompt. In non-interactive
533
+ * mode, resolves tools via manifest -> --tools flag -> detectCompatibleTools(assetType)
534
+ * -> exit 1, then dispatches RESOLVE_TOOLS_NONINTERACTIVE to advance directly to
535
+ * checking-registry.
536
+ */
537
+ const loadingTools = {
538
+ async run(state, ctx) {
539
+ if (!state.nonInteractive) {
540
+ const adapters = await loadAllAdapters();
541
+ if (ctx.cancelled())
542
+ return;
543
+ ctx.dispatch({ adapters, type: 'TOOLS_LOADED' });
544
+ return;
545
+ }
546
+ // Manifest tools win when present (VALIDATE_DONE only routes here when
547
+ // manifest tools are missing for assets — bundles short-circuit hasTools).
548
+ if (state.target?.type !== 'asset') {
549
+ // Bundles don't carry tools — just advance.
550
+ ctx.dispatch({ tools: [], type: 'RESOLVE_TOOLS_NONINTERACTIVE' });
551
+ return;
552
+ }
553
+ // Try --tools flag
554
+ let resolvedToolList = splitList(state.flags.toolsFlag);
555
+ if (state.flags.toolsFlag) {
556
+ // Validation ran before these tools were filled in, so check them here.
557
+ const toolsError = await unknownToolsError(resolvedToolList);
558
+ if (ctx.cancelled())
559
+ return;
560
+ if (toolsError) {
561
+ ctx.dispatch({ message: `--tools: ${toolsError}`, type: 'ERROR' });
562
+ return;
563
+ }
564
+ }
565
+ // Fall back to detectCompatibleTools
566
+ if (resolvedToolList.length === 0) {
567
+ const assetType = state.target.assetType;
568
+ if (!assetType) {
569
+ ctx.dispatch({
570
+ message: '--tools <comma-separated> required: no tools detected for type unknown',
571
+ type: 'ERROR',
572
+ });
573
+ return;
574
+ }
575
+ const detected = await detectCompatibleTools(assetType);
576
+ if (ctx.cancelled())
577
+ return;
578
+ if (detected.length === 0) {
579
+ ctx.dispatch({
580
+ message: `--tools <comma-separated> required: no tools detected for type ${assetType}`,
581
+ type: 'ERROR',
582
+ });
583
+ return;
584
+ }
585
+ resolvedToolList = detected;
586
+ }
587
+ ctx.dispatch({ tools: resolvedToolList, type: 'RESOLVE_TOOLS_NONINTERACTIVE' });
588
+ },
589
+ };
590
+ const checkingRegistry = {
591
+ async fail(err) {
592
+ return toErrorAction(err, mapPublishError(err));
593
+ },
594
+ async run(state, ctx) {
595
+ const target = state.target;
596
+ if (!target)
597
+ return;
598
+ const authToken = (await getCredential()).token;
599
+ if (ctx.cancelled())
600
+ return;
601
+ const reg = await fetchRegistry({ force: state.flags.refresh });
602
+ if (ctx.cancelled())
603
+ return;
604
+ const versionResult = checkRegistryVersion(target, reg);
605
+ // A version the registry doesn't have yet is published as it is; needs-bump settles it later.
606
+ const isUpdate = versionResult.status !== 'new-asset';
607
+ const resolvedVersion = versionResult.status === 'needs-bump' ? '' : target.data.version;
608
+ ctx.dispatch({
609
+ isUpdate,
610
+ resolvedVersion,
611
+ token: authToken,
612
+ type: 'REGISTRY_CHECK_DONE',
613
+ versionCheck: versionResult,
614
+ });
615
+ },
616
+ };
617
+ /**
618
+ * Validate each entry not yet checked the same way as the root asset, and check its
619
+ * version against the registry; advanceToPlan then settles any bump it needs.
620
+ */
621
+ const checkingEntries = {
622
+ async fail(err) {
623
+ return toErrorAction(err, mapPublishError(err));
624
+ },
625
+ async run(state, ctx) {
626
+ let lockfileOrg;
627
+ try {
628
+ lockfileOrg = (await readLockfile(findProjectRoot(state.flags.projectRoot))).org;
629
+ }
630
+ catch {
631
+ // No project root / lockfile — treat as no lockfile org
632
+ }
633
+ const registry = await fetchRegistry({ force: state.flags.refresh });
634
+ if (ctx.cancelled())
635
+ return;
636
+ const entries = [];
637
+ for (const entry of state.entries) {
638
+ if (entry.versionCheck) {
639
+ entries.push(entry);
640
+ continue;
641
+ }
642
+ const validation = entry.validation ?? (await validatePublishTarget(entry.target, { force: state.flags.force, lockfileOrg }));
643
+ if (ctx.cancelled())
644
+ return;
645
+ if (!validation.valid) {
646
+ ctx.dispatch({
647
+ message: `Validation failed for ${entry.label}:\n${validation.errors.map((e) => ` - ${e}`).join('\n')}`,
648
+ type: 'ERROR',
649
+ });
650
+ return;
651
+ }
652
+ const versionCheck = checkRegistryVersion(entry.target, registry);
653
+ entries.push({
654
+ ...entry,
655
+ ...(versionCheck.status === 'needs-bump' ? {} : { resolvedVersion: entry.target.data.version }),
656
+ validation,
657
+ versionCheck,
658
+ });
659
+ }
660
+ ctx.dispatch({ entries, type: 'ENTRIES_CHECKED' });
661
+ },
662
+ };
663
+ /**
664
+ * Plans from the targets in memory, which carry every bump and prompted tag or tool:
665
+ * nothing is written to a manifest until the publish succeeds (writeManifestEdits).
666
+ */
667
+ const buildingPlan = {
668
+ async fail(err, state, ctx) {
669
+ // Worded like the publishing phase: resolvePlanDependencies calls the same API.
670
+ const error = toErrorAction(err, mapPublishError(err, entryLabels(state), batchRootLabel(state)));
671
+ const suggestion = await suggestSiblingEntries(state, err).catch(() => undefined);
672
+ if (ctx.cancelled())
673
+ return undefined;
674
+ return suggestion ? { error, ...suggestion, type: 'SUGGEST_ENTRIES' } : error;
675
+ },
676
+ async run(state, ctx) {
677
+ const currentTarget = state.target;
678
+ if (!currentTarget)
679
+ return;
680
+ const currentVersion = state.resolvedVersion;
681
+ // Reject invalid reviewer usernames here, before any branch/commit/push work happens
682
+ const reviewers = parseReviewersInput(state.reviewerValue);
683
+ for (const reviewer of reviewers) {
684
+ const check = validateGithubUsername(reviewer);
685
+ if (!check.valid) {
686
+ throw new Error(check.error);
687
+ }
688
+ }
689
+ const entries = state.entries.map((entry) => ({ label: entry.label, target: entry.target }));
690
+ const localPlan = await buildPublishPlan(currentTarget, currentVersion, state.isUpdate, state.flags.message, reviewers, entries, ...(isBatch(state.flags) ? [state.flags.rawPath] : []));
691
+ if (ctx.cancelled())
692
+ return;
693
+ // Resolve declared dependencies against the registry now, so the preview and
694
+ // --dry-run show them and an unresolvable one stops before the confirm prompt.
695
+ const publishPlan = await resolvePlanDependencies(localPlan);
696
+ if (ctx.cancelled())
697
+ return;
698
+ let manifestDiff;
699
+ if (state.isUpdate && state.versionCheck && state.versionCheck.status !== 'new-asset' && state.token) {
700
+ const existing = await fetchExistingManifest(currentTarget, state.versionCheck.latestVersion);
701
+ if (ctx.cancelled())
702
+ return;
703
+ if (existing) {
704
+ manifestDiff = diffManifests(existing, currentTarget.data);
705
+ }
706
+ }
707
+ ctx.dispatch({ manifestDiff, plan: publishPlan, type: 'PLAN_DONE' });
708
+ },
709
+ };
710
+ const publishing = {
711
+ async fail(err, state) {
712
+ return toErrorAction(err, mapPublishError(err, entryLabels(state), batchRootLabel(state)));
713
+ },
714
+ async run(state, ctx) {
715
+ const plan = state.plan;
716
+ if (!plan || !state.token)
717
+ return;
718
+ const published = await executePublish(plan, (message) => ctx.dispatch({ message, type: 'PUBLISH_PROGRESS' }));
719
+ // The PR is open, so the folders take its versions, tags and tools even if the run was cancelled meanwhile.
720
+ const unwritten = await writeManifestEdits(state);
721
+ if (ctx.cancelled())
722
+ return;
723
+ const result = unwritten.length > 0 ? { ...published, warnings: [...(published.warnings ?? []), ...unwritten] } : published;
724
+ // Clear registry cache so subsequent commands see the new version
725
+ try {
726
+ await clearCache();
727
+ }
728
+ catch {
729
+ // Best-effort — don't fail publish over cache
730
+ }
731
+ // Update lockfile in-place when publishing from an installed asset
732
+ if (state.flags.fromInstalled || state.flags.update) {
733
+ try {
734
+ const projectRoot = findProjectRoot(state.flags.projectRoot);
735
+ const pubTools = await resolveTools(state.flags.tool);
736
+ const pubAdapter = pubTools[0].adapter;
737
+ await withLockfileLock(projectRoot, async () => {
738
+ const lockfile = await readLockfile(projectRoot);
739
+ const { name, org } = parseOrgFromName(state.flags.rawPath);
740
+ const installed = findInstalledAsset(lockfile, name, undefined, org);
741
+ if (installed) {
742
+ const resolved = resolveInstalledPaths(installed, pubAdapter, projectRoot);
743
+ const updatedFiles = await Promise.all(resolved.files.map(async (file) => ({
744
+ checksum: await hashFile(join(projectRoot, file.installedPath)),
745
+ sourcePath: file.sourcePath,
746
+ })));
747
+ const updatedLockfile = addAssetToLockfile(lockfile, {
748
+ ...installed,
749
+ files: updatedFiles,
750
+ version: state.resolvedVersion,
751
+ });
752
+ await writeLockfile(projectRoot, updatedLockfile);
753
+ }
754
+ });
755
+ }
756
+ catch {
757
+ // Best-effort — don't fail publish over lockfile update
758
+ }
759
+ }
760
+ if (ctx.cancelled())
761
+ return;
762
+ ctx.dispatch({ result, type: 'PUBLISH_DONE' });
763
+ },
764
+ };
765
+ /** A raw publish records the asset it published in the lockfile, as installed from the project's own files. */
766
+ const installing = {
767
+ async run(state, ctx) {
768
+ try {
769
+ const projectRoot = findProjectRoot(state.flags.projectRoot);
770
+ await withLockfileLock(projectRoot, async () => {
771
+ // Read current lockfile
772
+ const lockfile = await readLockfile(projectRoot);
773
+ // Compute checksums from actual project files (not staging copies)
774
+ const files = [];
775
+ for (const file of state.rawDetection.files) {
776
+ const checksum = await hashFile(file.absolutePath);
777
+ files.push({
778
+ checksum,
779
+ sourcePath: file.relativePath,
780
+ });
781
+ }
782
+ // Build InstalledAsset entry
783
+ const installedAsset = {
784
+ files,
785
+ installReason: 'direct',
786
+ name: state.rawDetection.name,
787
+ org: state.orgValue || undefined,
788
+ type: state.rawDetection.assetType,
789
+ version: state.resolvedVersion || state.target?.data.version || '1.0.0',
790
+ };
791
+ // Add to lockfile and write
792
+ const updatedLockfile = addAssetToLockfile(lockfile, installedAsset);
793
+ await writeLockfile(projectRoot, updatedLockfile);
794
+ });
795
+ }
796
+ catch {
797
+ // Publish already succeeded, so just warn and move to done
798
+ }
799
+ ctx.dispatch({ type: 'INSTALL_DONE' });
800
+ },
801
+ };
802
+ //# sourceMappingURL=publish-flow.js.map