@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.
- package/CHANGELOG.md +10 -0
- package/build/commands/publish.d.ts.map +1 -1
- package/build/commands/publish.js +2 -1
- package/build/commands/publish.js.map +1 -1
- package/build/hooks/usePublishState.d.ts +8 -212
- package/build/hooks/usePublishState.d.ts.map +1 -1
- package/build/hooks/usePublishState.js +38 -973
- package/build/hooks/usePublishState.js.map +1 -1
- package/build/lib/publish-flow.d.ts +232 -0
- package/build/lib/publish-flow.d.ts.map +1 -0
- package/build/lib/publish-flow.js +802 -0
- package/build/lib/publish-flow.js.map +1 -0
- package/build/lib/publisher.d.ts +2 -1
- package/build/lib/publisher.d.ts.map +1 -1
- package/build/lib/publisher.js +18 -17
- package/build/lib/publisher.js.map +1 -1
- package/build/lib/staging.d.ts +15 -0
- package/build/lib/staging.d.ts.map +1 -0
- package/build/lib/staging.js +33 -0
- package/build/lib/staging.js.map +1 -0
- package/package.json +1 -1
|
@@ -1,84 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
4
2
|
import { useEffect, useReducer, useRef } from 'react';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
import { errorGuidance, errorMessage } from '../lib/errors.js';
|
|
10
|
-
import { detectCompatibleTools, getSignedInUserName } from '../lib/init.js';
|
|
11
|
-
import { addAssetToLockfile, findInstalledAsset, readLockfile, withLockfileLock, writeLockfile, } from '../lib/lockfile.js';
|
|
12
|
-
import { diffManifests, fetchExistingManifest } from '../lib/manifest-diff.js';
|
|
13
|
-
import { parseOrgFromName } from '../lib/org.js';
|
|
14
|
-
import { findProjectRoot } from '../lib/paths.js';
|
|
15
|
-
import { buildPublishPlan, buildStagingArea, bumpVersion, checkRegistryVersion, detectAssetFromPath, detectPublishType, executePublish, findSiblingDependencies, isOrgScopedTarget, loadPublishBatch, loadPublishEntries, mapPublishError, missingDependencies, resolvePlanDependencies, unknownToolsError, updateManifestField, updateManifestVersion, validatePublishTarget, } from '../lib/publisher.js';
|
|
16
|
-
import { fetchAssetManifest } from '../lib/registry.js';
|
|
17
|
-
import { clearCache, fetchRegistry, findAsset, findBundle } from '../lib/registry.js';
|
|
18
|
-
import { parseReviewersInput, validateGithubUsername } from '../lib/reviewers.js';
|
|
19
|
-
import { resolveTools } from '../lib/tool-resolver.js';
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// Error helpers
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
export function createInitialState(flags) {
|
|
24
|
-
// Non-interactive when --yes, --dry-run, --json, or stdin is not a TTY (raw mode
|
|
25
|
-
// unsupported). Defaults isTTY to true when the flag is omitted (call site responsible
|
|
26
|
-
// for threading useStdin().isRawModeSupported in). --dry-run and --json force
|
|
27
|
-
// non-interactive regardless of TTY — JSON output cannot interleave with prompts.
|
|
28
|
-
const isTTY = flags.isTTY ?? true;
|
|
29
|
-
const nonInteractive = Boolean(flags.yes) || flags.dryRun || Boolean(flags.json) || !isTTY;
|
|
30
|
-
const flagError = batchFlagError(flags);
|
|
31
|
-
return {
|
|
32
|
-
authorValue: '',
|
|
33
|
-
bumpSelection: '',
|
|
34
|
-
confirmValue: '',
|
|
35
|
-
descriptionValue: '',
|
|
36
|
-
entries: [],
|
|
37
|
-
errorMessage: flagError ?? '',
|
|
38
|
-
flags,
|
|
39
|
-
isRawPublish: false,
|
|
40
|
-
isUpdate: false,
|
|
41
|
-
manifestDiff: undefined,
|
|
42
|
-
metadataSubPhase: 'description',
|
|
43
|
-
missingDependencies: [],
|
|
44
|
-
nonInteractive,
|
|
45
|
-
orgValue: '',
|
|
46
|
-
phase: flagError
|
|
47
|
-
? 'error'
|
|
48
|
-
: flags.update
|
|
49
|
-
? 'resolving-update'
|
|
50
|
-
: flags.fromInstalled
|
|
51
|
-
? 'resolving-installed'
|
|
52
|
-
: 'detecting',
|
|
53
|
-
plan: undefined,
|
|
54
|
-
progressMessage: '',
|
|
55
|
-
promptTagsValue: '',
|
|
56
|
-
publishConfirmValue: '',
|
|
57
|
-
publishResult: undefined,
|
|
58
|
-
rawDetection: null,
|
|
59
|
-
resolvedPath: flags.update ? '' : flags.fromInstalled ? '' : resolve(flags.rawPath),
|
|
60
|
-
resolvedVersion: '',
|
|
61
|
-
reviewerValue: '',
|
|
62
|
-
selectedPromptTools: [],
|
|
63
|
-
suggestedEntries: [],
|
|
64
|
-
tagsValue: '',
|
|
65
|
-
target: undefined,
|
|
66
|
-
token: undefined,
|
|
67
|
-
toolAdapters: [],
|
|
68
|
-
toolCursorIndex: 0,
|
|
69
|
-
toolsDetected: [],
|
|
70
|
-
validation: undefined,
|
|
71
|
-
versionCheck: undefined,
|
|
72
|
-
withConfirmValue: '',
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Initial state factory
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
/** Several folders published together (`atk publish a b c`) in one pull request; one folder named twice is one. */
|
|
79
|
-
export function isBatch(flags) {
|
|
80
|
-
return new Set(flags.paths?.map((path) => resolve(path))).size > 1;
|
|
81
|
-
}
|
|
3
|
+
import { createInitialState, isBatch, runPublishStep, splitList, } from '../lib/publish-flow.js';
|
|
4
|
+
import { bumpVersion, isOrgScopedTarget } from '../lib/publisher.js';
|
|
5
|
+
import { parseReviewersInput } from '../lib/reviewers.js';
|
|
6
|
+
export { createInitialState, isBatch } from '../lib/publish-flow.js';
|
|
82
7
|
export function publishReducer(state, action) {
|
|
83
8
|
switch (action.type) {
|
|
84
9
|
case 'BUMP_DONE': {
|
|
@@ -343,11 +268,7 @@ export function publishReducer(state, action) {
|
|
|
343
268
|
};
|
|
344
269
|
}
|
|
345
270
|
case 'SUBMIT_PROMPT_TAGS': {
|
|
346
|
-
|
|
347
|
-
const parsedTags = state.promptTagsValue
|
|
348
|
-
.split(',')
|
|
349
|
-
.map((t) => t.trim())
|
|
350
|
-
.filter(Boolean);
|
|
271
|
+
const parsedTags = splitList(state.promptTagsValue);
|
|
351
272
|
// Update the target's data with the new tags
|
|
352
273
|
const updatedTagsTarget = state.target
|
|
353
274
|
? {
|
|
@@ -488,12 +409,7 @@ export function publishReducer(state, action) {
|
|
|
488
409
|
if (state.nonInteractive) {
|
|
489
410
|
let nextTarget = state.target;
|
|
490
411
|
if (!hasTags && nextTarget) {
|
|
491
|
-
const flagTags = state.flags.tagsFlag
|
|
492
|
-
? state.flags.tagsFlag
|
|
493
|
-
.split(',')
|
|
494
|
-
.map((t) => t.trim())
|
|
495
|
-
.filter(Boolean)
|
|
496
|
-
: [];
|
|
412
|
+
const flagTags = splitList(state.flags.tagsFlag);
|
|
497
413
|
nextTarget = {
|
|
498
414
|
...nextTarget,
|
|
499
415
|
data: { ...nextTarget.data, tags: flagTags.length > 0 ? flagTags : undefined },
|
|
@@ -537,783 +453,56 @@ export function publishReducer(state, action) {
|
|
|
537
453
|
return state;
|
|
538
454
|
}
|
|
539
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* The publish flow's state for the command's UI. The reducer moves it through its
|
|
458
|
+
* phases; on each phase that waits on work, the step in `lib/publish-flow.ts` runs
|
|
459
|
+
* and dispatches what it found.
|
|
460
|
+
*/
|
|
540
461
|
export function usePublishState(flags) {
|
|
541
462
|
const [state, dispatch] = useReducer(publishReducer, undefined, () => createInitialState(flags));
|
|
542
|
-
//
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const resolveInstalled = async () => {
|
|
550
|
-
const assetName = state.flags.rawPath;
|
|
551
|
-
const { name, org } = parseOrgFromName(assetName);
|
|
552
|
-
const projectRoot = findProjectRoot(state.flags.projectRoot);
|
|
553
|
-
const tools = await resolveTools(state.flags.tool);
|
|
554
|
-
const adapter = tools[0].adapter;
|
|
555
|
-
const lockfile = await readLockfile(projectRoot);
|
|
556
|
-
const installed = findInstalledAsset(lockfile, name, undefined, org);
|
|
557
|
-
if (!installed) {
|
|
558
|
-
if (!cancelled) {
|
|
559
|
-
dispatch({ message: `Asset '${assetName}' is not installed.`, type: 'ERROR' });
|
|
560
|
-
}
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
// Create temp directory and copy installed files
|
|
564
|
-
const tempDir = await mkdir(join(tmpdir(), 'atk-publish-'), { recursive: true })
|
|
565
|
-
.then(() => join(tmpdir(), `atk-publish-${Date.now()}-${Math.random().toString(36).slice(2)}`))
|
|
566
|
-
.then(async (dir) => {
|
|
567
|
-
await mkdir(dir, { recursive: true });
|
|
568
|
-
return dir;
|
|
569
|
-
});
|
|
570
|
-
if (cancelled) {
|
|
571
|
-
await rm(tempDir, { force: true, recursive: true });
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
tempDirRef.current = tempDir;
|
|
575
|
-
// Resolve installed paths via the adapter
|
|
576
|
-
const resolved = resolveInstalledPaths(installed, adapter, projectRoot);
|
|
577
|
-
// Copy each installed file preserving sourcePath directory structure
|
|
578
|
-
for (const file of resolved.files) {
|
|
579
|
-
const sourceAbsolute = join(projectRoot, file.installedPath);
|
|
580
|
-
const destPath = join(tempDir, file.sourcePath);
|
|
581
|
-
const destDir = dirname(destPath);
|
|
582
|
-
await mkdir(destDir, { recursive: true });
|
|
583
|
-
await cp(sourceAbsolute, destPath);
|
|
584
|
-
}
|
|
585
|
-
// Generate manifest.json in the temp dir
|
|
586
|
-
const manifest = {
|
|
587
|
-
author: 'unknown',
|
|
588
|
-
description: `Published from installed asset ${installed.name}`,
|
|
589
|
-
entrypoint: installed.files[0]?.sourcePath ?? 'index.md',
|
|
590
|
-
name: installed.name,
|
|
591
|
-
...(installed.org ? { org: installed.org } : {}),
|
|
592
|
-
type: installed.type,
|
|
593
|
-
version: installed.version,
|
|
594
|
-
};
|
|
595
|
-
await writeFile(join(tempDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
596
|
-
if (!cancelled) {
|
|
597
|
-
dispatch({ resolvedPath: tempDir, type: 'RESOLVED_INSTALLED' });
|
|
598
|
-
}
|
|
599
|
-
};
|
|
600
|
-
resolveInstalled().catch((err) => {
|
|
601
|
-
if (!cancelled) {
|
|
602
|
-
dispatch(toErrorAction(err));
|
|
603
|
-
}
|
|
604
|
-
});
|
|
605
|
-
return () => {
|
|
606
|
-
cancelled = true;
|
|
607
|
-
};
|
|
608
|
-
// Phase-gated effect: only runs when entering 'resolving-installed'
|
|
609
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
610
|
-
}, [state.phase]);
|
|
611
|
-
// --- Phase: resolving-update ---
|
|
612
|
-
useEffect(() => {
|
|
613
|
-
if (state.phase !== 'resolving-update')
|
|
614
|
-
return;
|
|
615
|
-
let cancelled = false;
|
|
616
|
-
const resolveUpdate = async () => {
|
|
617
|
-
const assetName = state.flags.rawPath;
|
|
618
|
-
const { name, org } = parseOrgFromName(assetName);
|
|
619
|
-
const projectRoot = findProjectRoot(state.flags.projectRoot);
|
|
620
|
-
const tools = await resolveTools(state.flags.tool);
|
|
621
|
-
const adapter = tools[0].adapter;
|
|
622
|
-
const lockfile = await readLockfile(projectRoot);
|
|
623
|
-
const installed = findInstalledAsset(lockfile, name, undefined, org);
|
|
624
|
-
if (!installed) {
|
|
625
|
-
if (!cancelled) {
|
|
626
|
-
dispatch({ message: `Asset '${assetName}' is not installed.`, type: 'ERROR' });
|
|
627
|
-
}
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
// Fetch the registry to find the latest version
|
|
631
|
-
const registry = await fetchRegistry({ force: state.flags.refresh });
|
|
632
|
-
if (cancelled)
|
|
633
|
-
return;
|
|
634
|
-
// Look up the asset in the registry: try findAsset first, then findBundle
|
|
635
|
-
const resolvedAsset = findAsset(registry, installed.name, installed.type, undefined, installed.org ?? org);
|
|
636
|
-
const resolvedBundle = resolvedAsset ? undefined : findBundle(registry, installed.name, installed.org ?? org);
|
|
637
|
-
if (!resolvedAsset && !resolvedBundle) {
|
|
638
|
-
if (!cancelled) {
|
|
639
|
-
dispatch({ message: `Asset '${assetName}' not found in the registry.`, type: 'ERROR' });
|
|
640
|
-
}
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
const latestVersion = resolvedAsset ? resolvedAsset.version : resolvedBundle.version;
|
|
644
|
-
const assetType = resolvedAsset ? resolvedAsset.asset.type : 'bundle';
|
|
645
|
-
// Fetch the authoritative manifest (and optional README) from the registry
|
|
646
|
-
const fetched = await fetchAssetManifest({ name: installed.name, org: installed.org ?? org, type: assetType }, latestVersion);
|
|
647
|
-
if (cancelled)
|
|
648
|
-
return;
|
|
649
|
-
// Create temp staging directory
|
|
650
|
-
const tempDir = await mkdir(join(tmpdir(), 'atk-publish-'), { recursive: true })
|
|
651
|
-
.then(() => join(tmpdir(), `atk-publish-${Date.now()}-${Math.random().toString(36).slice(2)}`))
|
|
652
|
-
.then(async (dir) => {
|
|
653
|
-
await mkdir(dir, { recursive: true });
|
|
654
|
-
return dir;
|
|
463
|
+
// Staging directories (--from-installed, --update, a raw publish's manifest) to remove at the end
|
|
464
|
+
const stagingDirs = useRef([]);
|
|
465
|
+
const unmounted = useRef(false);
|
|
466
|
+
const removeStagingDirs = (dirs) => {
|
|
467
|
+
for (const dir of dirs.splice(0)) {
|
|
468
|
+
rm(dir, { force: true, recursive: true }).catch(() => {
|
|
469
|
+
// Best-effort cleanup — ignore errors
|
|
655
470
|
});
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
tempDirRef.current = tempDir;
|
|
661
|
-
// Resolve installed paths and copy the user's locally modified files into staging
|
|
662
|
-
const resolved = resolveInstalledPaths(installed, adapter, projectRoot);
|
|
663
|
-
for (const file of resolved.files) {
|
|
664
|
-
const sourceAbsolute = join(projectRoot, file.installedPath);
|
|
665
|
-
const destPath = join(tempDir, file.sourcePath);
|
|
666
|
-
const destDir = dirname(destPath);
|
|
667
|
-
await mkdir(destDir, { recursive: true });
|
|
668
|
-
await cp(sourceAbsolute, destPath);
|
|
669
|
-
}
|
|
670
|
-
// Write the fetched manifest into the staging directory. It is the manifest as this
|
|
671
|
-
// CLI's schema parsed it: a field the schema does not know (from a newer publisher)
|
|
672
|
-
// is dropped here and so is missing from the re-published version.
|
|
673
|
-
await writeFile(join(tempDir, fetched.manifestFileName), fetched.manifestContent, 'utf-8');
|
|
674
|
-
// Write README if present
|
|
675
|
-
if (fetched.readmeContent) {
|
|
676
|
-
await writeFile(join(tempDir, 'README.md'), fetched.readmeContent, 'utf-8');
|
|
677
|
-
}
|
|
678
|
-
if (!cancelled) {
|
|
679
|
-
dispatch({ resolvedPath: tempDir, type: 'RESOLVED_UPDATE' });
|
|
680
|
-
}
|
|
681
|
-
};
|
|
682
|
-
resolveUpdate().catch((err) => {
|
|
683
|
-
if (!cancelled) {
|
|
684
|
-
dispatch(toErrorAction(err));
|
|
685
|
-
}
|
|
686
|
-
});
|
|
687
|
-
return () => {
|
|
688
|
-
cancelled = true;
|
|
689
|
-
};
|
|
690
|
-
// Phase-gated effect: only runs when entering 'resolving-update'
|
|
691
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
692
|
-
}, [state.phase]);
|
|
693
|
-
// --- Phase: detecting ---
|
|
694
|
-
useEffect(() => {
|
|
695
|
-
if (state.phase !== 'detecting')
|
|
696
|
-
return;
|
|
697
|
-
let cancelled = false;
|
|
698
|
-
const detect = async () => {
|
|
699
|
-
// A batch reads every folder now; any that is missing, a bundle or not an asset folder
|
|
700
|
-
// fails here, all of them together, before any prompt.
|
|
701
|
-
if (isBatch(state.flags)) {
|
|
702
|
-
const { entries, root } = await loadPublishBatch(state.flags.paths ?? [], state.flags.withPaths);
|
|
703
|
-
if (cancelled)
|
|
704
|
-
return;
|
|
705
|
-
dispatch({ entries, type: 'ENTRIES_LOADED' });
|
|
706
|
-
dispatch({ target: root.target, type: 'DETECT_DONE' });
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
// A --with folder that is missing, a bundle, the root or a repeat fails here, before any
|
|
710
|
-
// prompt; its validation and version check come in checking-entries.
|
|
711
|
-
if (state.flags.withPaths?.length) {
|
|
712
|
-
const entries = await loadPublishEntries(state.flags.withPaths, state.resolvedPath);
|
|
713
|
-
if (cancelled)
|
|
714
|
-
return;
|
|
715
|
-
dispatch({ entries, type: 'ENTRIES_LOADED' });
|
|
716
|
-
}
|
|
717
|
-
// First, try manifest-based detection (existing flow)
|
|
718
|
-
try {
|
|
719
|
-
const detected = await detectPublishType(state.resolvedPath);
|
|
720
|
-
if (cancelled)
|
|
721
|
-
return;
|
|
722
|
-
dispatch({ target: detected, type: 'DETECT_DONE' });
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
catch (manifestError) {
|
|
726
|
-
// Only attempt raw detection if the error is specifically about missing manifest/bundle
|
|
727
|
-
const manifestMsg = errorMessage(manifestError);
|
|
728
|
-
if (!manifestMsg.includes('No manifest.json or bundle.json found')) {
|
|
729
|
-
// This is a different error (e.g., invalid JSON in manifest) — surface it directly
|
|
730
|
-
if (!cancelled) {
|
|
731
|
-
dispatch({ message: manifestMsg, type: 'ERROR' });
|
|
732
|
-
}
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
// Manifest not found — attempt raw asset detection
|
|
737
|
-
const resolvedTools = await resolveTools(state.flags.tool);
|
|
738
|
-
const adapter = resolvedTools[0].adapter;
|
|
739
|
-
if (cancelled)
|
|
740
|
-
return;
|
|
741
|
-
const projectRoot = findProjectRoot(state.flags.projectRoot);
|
|
742
|
-
const detection = await detectAssetFromPath(state.resolvedPath, adapter, projectRoot);
|
|
743
|
-
if (cancelled)
|
|
744
|
-
return;
|
|
745
|
-
// Pre-fill metadata
|
|
746
|
-
const author = (await getSignedInUserName()) ?? '';
|
|
747
|
-
if (cancelled)
|
|
748
|
-
return;
|
|
749
|
-
const lockfile = await readLockfile(projectRoot);
|
|
750
|
-
if (cancelled)
|
|
751
|
-
return;
|
|
752
|
-
const tools = await detectCompatibleTools(detection.assetType);
|
|
753
|
-
if (cancelled)
|
|
754
|
-
return;
|
|
755
|
-
const orgVal = lockfile.org ?? '';
|
|
756
|
-
// Non-interactive mode (raw publish — no manifest exists yet, so the only
|
|
757
|
-
// sources for description are --description flag or exit-1; tags come from
|
|
758
|
-
// --tags or default to empty). Tool detection runs for both modes above.
|
|
759
|
-
if (state.nonInteractive) {
|
|
760
|
-
if (!state.flags.descriptionFlag) {
|
|
761
|
-
if (!cancelled) {
|
|
762
|
-
dispatch({
|
|
763
|
-
message: 'description required: provide --description or include in manifest.json',
|
|
764
|
-
type: 'ERROR',
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
const tags = state.flags.tagsFlag
|
|
770
|
-
? state.flags.tagsFlag
|
|
771
|
-
.split(',')
|
|
772
|
-
.map((t) => t.trim())
|
|
773
|
-
.filter(Boolean)
|
|
774
|
-
: [];
|
|
775
|
-
// Tools resolution: --tools flag -> detected (already populated above)
|
|
776
|
-
let toolsList;
|
|
777
|
-
if (state.flags.toolsFlag) {
|
|
778
|
-
toolsList = state.flags.toolsFlag
|
|
779
|
-
.split(',')
|
|
780
|
-
.map((t) => t.trim())
|
|
781
|
-
.filter(Boolean);
|
|
782
|
-
}
|
|
783
|
-
else if (tools.length > 0) {
|
|
784
|
-
toolsList = tools;
|
|
785
|
-
}
|
|
786
|
-
else {
|
|
787
|
-
if (!cancelled) {
|
|
788
|
-
dispatch({
|
|
789
|
-
message: `--tools <comma-separated> required: no tools detected for type ${detection.assetType}`,
|
|
790
|
-
type: 'ERROR',
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
return;
|
|
794
|
-
}
|
|
795
|
-
const stagingDir = await buildStagingArea(detection, {
|
|
796
|
-
author,
|
|
797
|
-
description: state.flags.descriptionFlag,
|
|
798
|
-
...(lockfile.org ? { org: lockfile.org } : {}),
|
|
799
|
-
tags,
|
|
800
|
-
tools: toolsList,
|
|
801
|
-
});
|
|
802
|
-
if (cancelled)
|
|
803
|
-
return;
|
|
804
|
-
tempDirRef.current = stagingDir;
|
|
805
|
-
const detected = await detectPublishType(stagingDir);
|
|
806
|
-
if (cancelled)
|
|
807
|
-
return;
|
|
808
|
-
dispatch({ rawDetection: detection, target: detected, type: 'RAW_DETECT_DONE_NON_INTERACTIVE' });
|
|
809
|
-
}
|
|
810
|
-
else {
|
|
811
|
-
// Interactive mode — gather metadata from the user
|
|
812
|
-
dispatch({
|
|
813
|
-
authorValue: author,
|
|
814
|
-
orgValue: orgVal,
|
|
815
|
-
rawDetection: detection,
|
|
816
|
-
toolsDetected: tools,
|
|
817
|
-
type: 'RAW_DETECT_DONE',
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
};
|
|
821
|
-
detect().catch((err) => {
|
|
822
|
-
if (!cancelled) {
|
|
823
|
-
dispatch(toErrorAction(err));
|
|
824
|
-
}
|
|
825
|
-
});
|
|
826
|
-
return () => {
|
|
827
|
-
cancelled = true;
|
|
828
|
-
};
|
|
829
|
-
// Phase-gated effect: only runs when entering 'detecting'
|
|
830
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
831
|
-
}, [state.phase]);
|
|
832
|
-
// --- Phase: gathering-metadata / building-staging sub-phase ---
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
// Phase-gated: the step runs once on entering its phase (or the building-staging sub-phase)
|
|
833
474
|
useEffect(() => {
|
|
834
|
-
if (state.phase !== 'gathering-metadata' || state.metadataSubPhase !== 'building-staging')
|
|
835
|
-
return;
|
|
836
475
|
let cancelled = false;
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
.
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
description: state.descriptionValue,
|
|
847
|
-
org: state.orgValue || undefined,
|
|
848
|
-
tags,
|
|
849
|
-
tools: state.toolsDetected,
|
|
850
|
-
});
|
|
851
|
-
if (cancelled)
|
|
852
|
-
return;
|
|
853
|
-
// Store for cleanup
|
|
854
|
-
tempDirRef.current = stagingDir;
|
|
855
|
-
// Re-detect using the staging area (now has manifest.json)
|
|
856
|
-
const detectedTarget = await detectPublishType(stagingDir);
|
|
857
|
-
if (cancelled)
|
|
858
|
-
return;
|
|
859
|
-
dispatch({ target: detectedTarget, type: 'STAGING_DONE' });
|
|
860
|
-
};
|
|
861
|
-
build().catch((err) => {
|
|
862
|
-
if (!cancelled) {
|
|
863
|
-
dispatch(toErrorAction(err));
|
|
864
|
-
}
|
|
476
|
+
void runPublishStep(state, {
|
|
477
|
+
cancelled: () => cancelled,
|
|
478
|
+
dispatch,
|
|
479
|
+
stage: (dir) => {
|
|
480
|
+
stagingDirs.current.push(dir);
|
|
481
|
+
// A step that outlived the command still leaves nothing behind
|
|
482
|
+
if (unmounted.current)
|
|
483
|
+
removeStagingDirs(stagingDirs.current);
|
|
484
|
+
},
|
|
865
485
|
});
|
|
866
486
|
return () => {
|
|
867
487
|
cancelled = true;
|
|
868
488
|
};
|
|
869
|
-
// Phase-gated effect: only runs when entering building-staging sub-phase
|
|
870
489
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
871
490
|
}, [state.phase, state.metadataSubPhase]);
|
|
872
|
-
// --- Phase: validating ---
|
|
873
|
-
useEffect(() => {
|
|
874
|
-
if (state.phase !== 'validating')
|
|
875
|
-
return;
|
|
876
|
-
if (!state.target)
|
|
877
|
-
return;
|
|
878
|
-
let cancelled = false;
|
|
879
|
-
const validate = async () => {
|
|
880
|
-
let lockfileOrg;
|
|
881
|
-
let lockfileOrgReviewers;
|
|
882
|
-
try {
|
|
883
|
-
const projectRoot = findProjectRoot(state.flags.projectRoot);
|
|
884
|
-
const lockfile = await readLockfile(projectRoot);
|
|
885
|
-
lockfileOrg = lockfile.org;
|
|
886
|
-
lockfileOrgReviewers = lockfile.orgReviewers;
|
|
887
|
-
}
|
|
888
|
-
catch {
|
|
889
|
-
// No project root / lockfile — treat as no lockfile org / reviewers
|
|
890
|
-
}
|
|
891
|
-
if (cancelled)
|
|
892
|
-
return;
|
|
893
|
-
// Bundles need the registry so their asset refs can be resolved during
|
|
894
|
-
// validation — an unresolvable ref must fail here, not at install time.
|
|
895
|
-
let registry;
|
|
896
|
-
if (state.target.type === 'bundle') {
|
|
897
|
-
registry = await fetchRegistry({ force: state.flags.refresh });
|
|
898
|
-
if (cancelled)
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
const result = await validatePublishTarget(state.target, {
|
|
902
|
-
force: state.flags.force,
|
|
903
|
-
lockfileOrg,
|
|
904
|
-
registry,
|
|
905
|
-
});
|
|
906
|
-
if (cancelled)
|
|
907
|
-
return;
|
|
908
|
-
// A batch checks every asset before stopping, so one run lists every folder's problems.
|
|
909
|
-
if (isBatch(state.flags)) {
|
|
910
|
-
const entries = [];
|
|
911
|
-
for (const entry of state.entries) {
|
|
912
|
-
const validation = await validatePublishTarget(entry.target, { force: state.flags.force, lockfileOrg });
|
|
913
|
-
if (cancelled)
|
|
914
|
-
return;
|
|
915
|
-
entries.push({ ...entry, validation });
|
|
916
|
-
}
|
|
917
|
-
const failed = [{ label: state.flags.rawPath, validation: result }, ...entries].filter((f) => f.validation && !f.validation.valid);
|
|
918
|
-
if (failed.length > 0) {
|
|
919
|
-
const lines = failed.flatMap((f) => [
|
|
920
|
-
` ${f.label}:`,
|
|
921
|
-
...(f.validation?.errors ?? []).map((e) => ` - ${e}`),
|
|
922
|
-
]);
|
|
923
|
-
dispatch({ message: `Validation failed:\n${lines.join('\n')}`, type: 'ERROR' });
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
// checking-entries keeps these instead of validating each entry again.
|
|
927
|
-
dispatch({ entries, type: 'ENTRIES_LOADED' });
|
|
928
|
-
}
|
|
929
|
-
dispatch({ lockfileOrgReviewers, type: 'VALIDATE_DONE', validation: result });
|
|
930
|
-
};
|
|
931
|
-
validate().catch((err) => {
|
|
932
|
-
if (!cancelled) {
|
|
933
|
-
dispatch(toErrorAction(err));
|
|
934
|
-
}
|
|
935
|
-
});
|
|
936
|
-
return () => {
|
|
937
|
-
cancelled = true;
|
|
938
|
-
};
|
|
939
|
-
// Phase-gated effect: only runs when entering 'validating'
|
|
940
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
941
|
-
}, [state.phase]);
|
|
942
|
-
// --- Phase: loading-tools ---
|
|
943
|
-
// In interactive mode, loads adapters for the multi-select prompt.
|
|
944
|
-
// In non-interactive mode, resolves tools via manifest -> --tools flag ->
|
|
945
|
-
// detectCompatibleTools(assetType) -> exit 1, then dispatches
|
|
946
|
-
// RESOLVE_TOOLS_NONINTERACTIVE to advance directly to checking-registry.
|
|
947
|
-
useEffect(() => {
|
|
948
|
-
if (state.phase !== 'loading-tools')
|
|
949
|
-
return;
|
|
950
|
-
let cancelled = false;
|
|
951
|
-
const load = async () => {
|
|
952
|
-
if (state.nonInteractive) {
|
|
953
|
-
// Manifest tools win when present (VALIDATE_DONE only routes here when
|
|
954
|
-
// manifest tools are missing for assets — bundles short-circuit hasTools).
|
|
955
|
-
if (state.target?.type !== 'asset') {
|
|
956
|
-
// Bundles don't carry tools — just advance.
|
|
957
|
-
dispatch({ tools: [], type: 'RESOLVE_TOOLS_NONINTERACTIVE' });
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
// Try --tools flag
|
|
961
|
-
let resolvedToolList = [];
|
|
962
|
-
if (state.flags.toolsFlag) {
|
|
963
|
-
resolvedToolList = state.flags.toolsFlag
|
|
964
|
-
.split(',')
|
|
965
|
-
.map((t) => t.trim())
|
|
966
|
-
.filter(Boolean);
|
|
967
|
-
// Validation ran before these tools were filled in, so check them here.
|
|
968
|
-
const toolsError = await unknownToolsError(resolvedToolList);
|
|
969
|
-
if (cancelled)
|
|
970
|
-
return;
|
|
971
|
-
if (toolsError) {
|
|
972
|
-
dispatch({ message: `--tools: ${toolsError}`, type: 'ERROR' });
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
// Fall back to detectCompatibleTools
|
|
977
|
-
if (resolvedToolList.length === 0) {
|
|
978
|
-
const assetType = state.target.assetType;
|
|
979
|
-
if (!assetType) {
|
|
980
|
-
dispatch({
|
|
981
|
-
message: '--tools <comma-separated> required: no tools detected for type unknown',
|
|
982
|
-
type: 'ERROR',
|
|
983
|
-
});
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
const detected = await detectCompatibleTools(assetType);
|
|
987
|
-
if (cancelled)
|
|
988
|
-
return;
|
|
989
|
-
if (detected.length === 0) {
|
|
990
|
-
dispatch({
|
|
991
|
-
message: `--tools <comma-separated> required: no tools detected for type ${assetType}`,
|
|
992
|
-
type: 'ERROR',
|
|
993
|
-
});
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
resolvedToolList = detected;
|
|
997
|
-
}
|
|
998
|
-
dispatch({ tools: resolvedToolList, type: 'RESOLVE_TOOLS_NONINTERACTIVE' });
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
const adapters = await loadAllAdapters();
|
|
1002
|
-
if (cancelled)
|
|
1003
|
-
return;
|
|
1004
|
-
dispatch({ adapters, type: 'TOOLS_LOADED' });
|
|
1005
|
-
};
|
|
1006
|
-
load().catch((err) => {
|
|
1007
|
-
if (!cancelled) {
|
|
1008
|
-
dispatch(toErrorAction(err));
|
|
1009
|
-
}
|
|
1010
|
-
});
|
|
1011
|
-
return () => {
|
|
1012
|
-
cancelled = true;
|
|
1013
|
-
};
|
|
1014
|
-
// Phase-gated effect: only runs when entering 'loading-tools'
|
|
1015
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1016
|
-
}, [state.phase]);
|
|
1017
|
-
// --- Phase: checking-registry ---
|
|
1018
|
-
useEffect(() => {
|
|
1019
|
-
if (state.phase !== 'checking-registry')
|
|
1020
|
-
return;
|
|
1021
|
-
if (!state.target)
|
|
1022
|
-
return;
|
|
1023
|
-
let cancelled = false;
|
|
1024
|
-
const check = async () => {
|
|
1025
|
-
const authToken = (await getCredential()).token;
|
|
1026
|
-
if (cancelled)
|
|
1027
|
-
return;
|
|
1028
|
-
const reg = await fetchRegistry({ force: state.flags.refresh });
|
|
1029
|
-
if (cancelled)
|
|
1030
|
-
return;
|
|
1031
|
-
const versionResult = checkRegistryVersion(state.target, reg);
|
|
1032
|
-
if (cancelled)
|
|
1033
|
-
return;
|
|
1034
|
-
let isUpdate;
|
|
1035
|
-
let resolvedVersion = '';
|
|
1036
|
-
if (versionResult.status === 'new-asset') {
|
|
1037
|
-
isUpdate = false;
|
|
1038
|
-
resolvedVersion = state.target.data.version;
|
|
1039
|
-
}
|
|
1040
|
-
else if (versionResult.status === 'version-already-bumped') {
|
|
1041
|
-
isUpdate = true;
|
|
1042
|
-
resolvedVersion = state.target.data.version;
|
|
1043
|
-
}
|
|
1044
|
-
else {
|
|
1045
|
-
// needs-bump
|
|
1046
|
-
isUpdate = true;
|
|
1047
|
-
}
|
|
1048
|
-
if (cancelled)
|
|
1049
|
-
return;
|
|
1050
|
-
dispatch({
|
|
1051
|
-
isUpdate,
|
|
1052
|
-
resolvedVersion,
|
|
1053
|
-
token: authToken,
|
|
1054
|
-
type: 'REGISTRY_CHECK_DONE',
|
|
1055
|
-
versionCheck: versionResult,
|
|
1056
|
-
});
|
|
1057
|
-
};
|
|
1058
|
-
check().catch((err) => {
|
|
1059
|
-
if (!cancelled) {
|
|
1060
|
-
dispatch(toErrorAction(err, mapPublishError(err)));
|
|
1061
|
-
}
|
|
1062
|
-
});
|
|
1063
|
-
return () => {
|
|
1064
|
-
cancelled = true;
|
|
1065
|
-
};
|
|
1066
|
-
// Phase-gated effect: only runs when entering 'checking-registry'
|
|
1067
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1068
|
-
}, [state.phase]);
|
|
1069
|
-
// --- Phase: checking-entries ---
|
|
1070
|
-
// Validate each entry not yet checked the same way as the root asset, and check its
|
|
1071
|
-
// version against the registry; advanceToPlan then settles any bump it needs.
|
|
1072
|
-
useEffect(() => {
|
|
1073
|
-
if (state.phase !== 'checking-entries')
|
|
1074
|
-
return;
|
|
1075
|
-
let cancelled = false;
|
|
1076
|
-
const check = async () => {
|
|
1077
|
-
let lockfileOrg;
|
|
1078
|
-
try {
|
|
1079
|
-
lockfileOrg = (await readLockfile(findProjectRoot(state.flags.projectRoot))).org;
|
|
1080
|
-
}
|
|
1081
|
-
catch {
|
|
1082
|
-
// No project root / lockfile — treat as no lockfile org
|
|
1083
|
-
}
|
|
1084
|
-
const registry = await fetchRegistry({ force: state.flags.refresh });
|
|
1085
|
-
if (cancelled)
|
|
1086
|
-
return;
|
|
1087
|
-
const entries = [];
|
|
1088
|
-
for (const entry of state.entries) {
|
|
1089
|
-
if (entry.versionCheck) {
|
|
1090
|
-
entries.push(entry);
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
const validation = entry.validation ?? (await validatePublishTarget(entry.target, { force: state.flags.force, lockfileOrg }));
|
|
1094
|
-
if (cancelled)
|
|
1095
|
-
return;
|
|
1096
|
-
if (!validation.valid) {
|
|
1097
|
-
dispatch({
|
|
1098
|
-
message: `Validation failed for ${entry.label}:\n${validation.errors.map((e) => ` - ${e}`).join('\n')}`,
|
|
1099
|
-
type: 'ERROR',
|
|
1100
|
-
});
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
const versionCheck = checkRegistryVersion(entry.target, registry);
|
|
1104
|
-
entries.push({
|
|
1105
|
-
...entry,
|
|
1106
|
-
...(versionCheck.status === 'needs-bump' ? {} : { resolvedVersion: entry.target.data.version }),
|
|
1107
|
-
validation,
|
|
1108
|
-
versionCheck,
|
|
1109
|
-
});
|
|
1110
|
-
}
|
|
1111
|
-
dispatch({ entries, type: 'ENTRIES_CHECKED' });
|
|
1112
|
-
};
|
|
1113
|
-
check().catch((err) => {
|
|
1114
|
-
if (!cancelled) {
|
|
1115
|
-
dispatch(toErrorAction(err, mapPublishError(err)));
|
|
1116
|
-
}
|
|
1117
|
-
});
|
|
1118
|
-
return () => {
|
|
1119
|
-
cancelled = true;
|
|
1120
|
-
};
|
|
1121
|
-
// Phase-gated effect: only runs when entering 'checking-entries'
|
|
1122
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1123
|
-
}, [state.phase]);
|
|
1124
|
-
// --- Phase: building-plan ---
|
|
1125
|
-
// Plans from the targets in memory, which carry every bump and prompted tag or tool:
|
|
1126
|
-
// nothing is written to a manifest until the publish succeeds (writeManifestEdits).
|
|
1127
|
-
useEffect(() => {
|
|
1128
|
-
if (state.phase !== 'building-plan')
|
|
1129
|
-
return;
|
|
1130
|
-
if (!state.target)
|
|
1131
|
-
return;
|
|
1132
|
-
let cancelled = false;
|
|
1133
|
-
const build = async () => {
|
|
1134
|
-
const currentTarget = state.target;
|
|
1135
|
-
const currentVersion = state.resolvedVersion;
|
|
1136
|
-
// Reject invalid reviewer usernames here, before any branch/commit/push work happens
|
|
1137
|
-
const reviewers = parseReviewersInput(state.reviewerValue);
|
|
1138
|
-
for (const reviewer of reviewers) {
|
|
1139
|
-
const check = validateGithubUsername(reviewer);
|
|
1140
|
-
if (!check.valid) {
|
|
1141
|
-
throw new Error(check.error);
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
const entries = state.entries.map((entry) => ({ label: entry.label, target: entry.target }));
|
|
1145
|
-
const localPlan = await buildPublishPlan(currentTarget, currentVersion, state.isUpdate, state.flags.message, reviewers, entries, ...(isBatch(state.flags) ? [state.flags.rawPath] : []));
|
|
1146
|
-
if (cancelled)
|
|
1147
|
-
return;
|
|
1148
|
-
// Resolve declared dependencies against the registry now, so the preview and
|
|
1149
|
-
// --dry-run show them and an unresolvable one stops before the confirm prompt.
|
|
1150
|
-
const publishPlan = await resolvePlanDependencies(localPlan);
|
|
1151
|
-
if (cancelled)
|
|
1152
|
-
return;
|
|
1153
|
-
let manifestDiff;
|
|
1154
|
-
if (state.isUpdate && state.versionCheck && state.versionCheck.status !== 'new-asset' && state.token) {
|
|
1155
|
-
const existing = await fetchExistingManifest(currentTarget, state.versionCheck.latestVersion);
|
|
1156
|
-
if (cancelled)
|
|
1157
|
-
return;
|
|
1158
|
-
if (existing) {
|
|
1159
|
-
manifestDiff = diffManifests(existing, currentTarget.data);
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
dispatch({ manifestDiff, plan: publishPlan, type: 'PLAN_DONE' });
|
|
1163
|
-
};
|
|
1164
|
-
build().catch(async (err) => {
|
|
1165
|
-
if (cancelled)
|
|
1166
|
-
return;
|
|
1167
|
-
// Worded like the publishing phase: resolvePlanDependencies calls the same API.
|
|
1168
|
-
const error = toErrorAction(err, mapPublishError(err, entryLabels(state), batchRootLabel(state)));
|
|
1169
|
-
const suggestion = await suggestSiblingEntries(state, err).catch(() => undefined);
|
|
1170
|
-
if (cancelled)
|
|
1171
|
-
return;
|
|
1172
|
-
dispatch(suggestion ? { error, ...suggestion, type: 'SUGGEST_ENTRIES' } : error);
|
|
1173
|
-
});
|
|
1174
|
-
return () => {
|
|
1175
|
-
cancelled = true;
|
|
1176
|
-
};
|
|
1177
|
-
// Phase-gated effect: only runs when entering 'building-plan'
|
|
1178
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1179
|
-
}, [state.phase]);
|
|
1180
|
-
// --- Phase: publishing ---
|
|
1181
|
-
useEffect(() => {
|
|
1182
|
-
if (state.phase !== 'publishing')
|
|
1183
|
-
return;
|
|
1184
|
-
if (!state.plan || !state.token)
|
|
1185
|
-
return;
|
|
1186
|
-
let cancelled = false;
|
|
1187
|
-
const publish = async () => {
|
|
1188
|
-
const progressCallback = (msg) => {
|
|
1189
|
-
if (!cancelled) {
|
|
1190
|
-
dispatch({ message: msg, type: 'PUBLISH_PROGRESS' });
|
|
1191
|
-
}
|
|
1192
|
-
};
|
|
1193
|
-
const published = await executePublish(state.plan, progressCallback);
|
|
1194
|
-
// The PR is open, so the folders take its versions, tags and tools even if the run was cancelled meanwhile.
|
|
1195
|
-
const unwritten = await writeManifestEdits(state);
|
|
1196
|
-
if (cancelled)
|
|
1197
|
-
return;
|
|
1198
|
-
const result = unwritten.length > 0 ? { ...published, warnings: [...(published.warnings ?? []), ...unwritten] } : published;
|
|
1199
|
-
// Clear registry cache so subsequent commands see the new version
|
|
1200
|
-
try {
|
|
1201
|
-
await clearCache();
|
|
1202
|
-
}
|
|
1203
|
-
catch {
|
|
1204
|
-
// Best-effort — don't fail publish over cache
|
|
1205
|
-
}
|
|
1206
|
-
// Update lockfile in-place when publishing from an installed asset
|
|
1207
|
-
if (state.flags.fromInstalled || state.flags.update) {
|
|
1208
|
-
try {
|
|
1209
|
-
const projectRoot = findProjectRoot(state.flags.projectRoot);
|
|
1210
|
-
const pubTools = await resolveTools(state.flags.tool);
|
|
1211
|
-
const pubAdapter = pubTools[0].adapter;
|
|
1212
|
-
await withLockfileLock(projectRoot, async () => {
|
|
1213
|
-
const lockfile = await readLockfile(projectRoot);
|
|
1214
|
-
const { name, org } = parseOrgFromName(state.flags.rawPath);
|
|
1215
|
-
const installed = findInstalledAsset(lockfile, name, undefined, org);
|
|
1216
|
-
if (installed) {
|
|
1217
|
-
const resolved = resolveInstalledPaths(installed, pubAdapter, projectRoot);
|
|
1218
|
-
const updatedFiles = await Promise.all(resolved.files.map(async (file) => ({
|
|
1219
|
-
checksum: await hashFile(join(projectRoot, file.installedPath)),
|
|
1220
|
-
sourcePath: file.sourcePath,
|
|
1221
|
-
})));
|
|
1222
|
-
const updatedLockfile = addAssetToLockfile(lockfile, {
|
|
1223
|
-
...installed,
|
|
1224
|
-
files: updatedFiles,
|
|
1225
|
-
version: state.resolvedVersion,
|
|
1226
|
-
});
|
|
1227
|
-
await writeLockfile(projectRoot, updatedLockfile);
|
|
1228
|
-
}
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
|
-
catch {
|
|
1232
|
-
// Best-effort — don't fail publish over lockfile update
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
if (cancelled)
|
|
1236
|
-
return;
|
|
1237
|
-
dispatch({ result, type: 'PUBLISH_DONE' });
|
|
1238
|
-
};
|
|
1239
|
-
publish().catch((err) => {
|
|
1240
|
-
if (!cancelled) {
|
|
1241
|
-
dispatch(toErrorAction(err, mapPublishError(err, entryLabels(state), batchRootLabel(state))));
|
|
1242
|
-
}
|
|
1243
|
-
});
|
|
1244
|
-
return () => {
|
|
1245
|
-
cancelled = true;
|
|
1246
|
-
};
|
|
1247
|
-
// Phase-gated effect: only runs when entering 'publishing'
|
|
1248
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1249
|
-
}, [state.phase]);
|
|
1250
|
-
// --- Phase: installing (auto-install raw publish to lockfile) ---
|
|
1251
|
-
useEffect(() => {
|
|
1252
|
-
if (state.phase !== 'installing')
|
|
1253
|
-
return;
|
|
1254
|
-
let cancelled = false;
|
|
1255
|
-
const install = async () => {
|
|
1256
|
-
try {
|
|
1257
|
-
const projectRoot = findProjectRoot(state.flags.projectRoot);
|
|
1258
|
-
await withLockfileLock(projectRoot, async () => {
|
|
1259
|
-
// Read current lockfile
|
|
1260
|
-
const lockfile = await readLockfile(projectRoot);
|
|
1261
|
-
// Compute checksums from actual project files (not staging copies)
|
|
1262
|
-
const files = [];
|
|
1263
|
-
for (const file of state.rawDetection.files) {
|
|
1264
|
-
const checksum = await hashFile(file.absolutePath);
|
|
1265
|
-
files.push({
|
|
1266
|
-
checksum,
|
|
1267
|
-
sourcePath: file.relativePath,
|
|
1268
|
-
});
|
|
1269
|
-
}
|
|
1270
|
-
// Build InstalledAsset entry
|
|
1271
|
-
const installedAsset = {
|
|
1272
|
-
files,
|
|
1273
|
-
installReason: 'direct',
|
|
1274
|
-
name: state.rawDetection.name,
|
|
1275
|
-
org: state.orgValue || undefined,
|
|
1276
|
-
type: state.rawDetection.assetType,
|
|
1277
|
-
version: state.resolvedVersion || state.target?.data.version || '1.0.0',
|
|
1278
|
-
};
|
|
1279
|
-
// Add to lockfile and write
|
|
1280
|
-
const updatedLockfile = addAssetToLockfile(lockfile, installedAsset);
|
|
1281
|
-
await writeLockfile(projectRoot, updatedLockfile);
|
|
1282
|
-
});
|
|
1283
|
-
if (!cancelled) {
|
|
1284
|
-
dispatch({ type: 'INSTALL_DONE' });
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
catch {
|
|
1288
|
-
// Publish already succeeded, so just warn and move to done
|
|
1289
|
-
if (!cancelled) {
|
|
1290
|
-
dispatch({ type: 'INSTALL_DONE' });
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
};
|
|
1294
|
-
install();
|
|
1295
|
-
return () => {
|
|
1296
|
-
cancelled = true;
|
|
1297
|
-
};
|
|
1298
|
-
// Phase-gated effect: only runs when entering 'installing'
|
|
1299
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1300
|
-
}, [state.phase]);
|
|
1301
491
|
// --- Cleanup temp directory on error or done ---
|
|
1302
492
|
useEffect(() => {
|
|
1303
493
|
if (state.phase !== 'error' && state.phase !== 'done' && state.phase !== 'dry-run-result')
|
|
1304
494
|
return;
|
|
1305
|
-
|
|
1306
|
-
const dir = tempDirRef.current;
|
|
1307
|
-
tempDirRef.current = null;
|
|
1308
|
-
rm(dir, { force: true, recursive: true }).catch(() => {
|
|
1309
|
-
// Best-effort cleanup — ignore errors
|
|
1310
|
-
});
|
|
1311
|
-
}
|
|
495
|
+
removeStagingDirs(stagingDirs.current);
|
|
1312
496
|
}, [state.phase]);
|
|
497
|
+
// --- ...or when the command goes away before either ---
|
|
498
|
+
useEffect(() => () => {
|
|
499
|
+
unmounted.current = true;
|
|
500
|
+
removeStagingDirs(stagingDirs.current);
|
|
501
|
+
}, []);
|
|
1313
502
|
return [state, dispatch];
|
|
1314
503
|
}
|
|
1315
504
|
// ---------------------------------------------------------------------------
|
|
1316
|
-
// Pure reducer
|
|
505
|
+
// Pure reducer helpers
|
|
1317
506
|
// ---------------------------------------------------------------------------
|
|
1318
507
|
/**
|
|
1319
508
|
* The step before `building-plan`, once the root asset's own checks are done:
|
|
@@ -1361,27 +550,6 @@ function advanceToPlan(state) {
|
|
|
1361
550
|
}
|
|
1362
551
|
return bumpEntryAt(state, index, state.flags.bump);
|
|
1363
552
|
}
|
|
1364
|
-
/** Why the flags can't publish a batch: one installed asset, or one asset's metadata. Undefined otherwise. */
|
|
1365
|
-
function batchFlagError(flags) {
|
|
1366
|
-
if (!isBatch(flags))
|
|
1367
|
-
return undefined;
|
|
1368
|
-
if (flags.update || flags.fromInstalled) {
|
|
1369
|
-
return `${flags.update ? '--update' : '--from-installed'} publishes one installed asset; leave it out to publish several folders.`;
|
|
1370
|
-
}
|
|
1371
|
-
const perAsset = [
|
|
1372
|
-
['--description', flags.descriptionFlag],
|
|
1373
|
-
['--tags', flags.tagsFlag],
|
|
1374
|
-
['--tools', flags.toolsFlag],
|
|
1375
|
-
].flatMap(([flag, value]) => (value === undefined ? [] : [flag]));
|
|
1376
|
-
if (perAsset.length > 0) {
|
|
1377
|
-
return `${perAsset.join(', ')} ${perAsset.length === 1 ? 'sets' : 'set'} one asset's metadata; put it in each folder's manifest.json to publish several folders.`;
|
|
1378
|
-
}
|
|
1379
|
-
return undefined;
|
|
1380
|
-
}
|
|
1381
|
-
/** In a batch, what the API calls `Root asset`, as the folder the user named. */
|
|
1382
|
-
function batchRootLabel(state) {
|
|
1383
|
-
return isBatch(state.flags) ? state.flags.rawPath : undefined;
|
|
1384
|
-
}
|
|
1385
553
|
/**
|
|
1386
554
|
* Bump the entry at `index` from its registry version, then go on toward the
|
|
1387
555
|
* plan; an error state, as for the root, when the version can't be bumped.
|
|
@@ -1399,25 +567,6 @@ function bumpEntryAt(state, index, bump) {
|
|
|
1399
567
|
}
|
|
1400
568
|
return advanceToPlan({ ...state, entries: replaceEntry(state.entries, index, withVersion(entry, version)) });
|
|
1401
569
|
}
|
|
1402
|
-
/** What the API calls `dependencies[i]`, as the folder the user named. */
|
|
1403
|
-
function entryLabels(state) {
|
|
1404
|
-
return state.entries.map((entry) => entry.label);
|
|
1405
|
-
}
|
|
1406
|
-
// ---------------------------------------------------------------------------
|
|
1407
|
-
// Hook
|
|
1408
|
-
// ---------------------------------------------------------------------------
|
|
1409
|
-
/** Run the writes in order until one throws: its index and error, or undefined when all succeed. */
|
|
1410
|
-
async function firstFailure(edits) {
|
|
1411
|
-
for (const [index, edit] of edits.entries()) {
|
|
1412
|
-
try {
|
|
1413
|
-
await edit.write();
|
|
1414
|
-
}
|
|
1415
|
-
catch (error) {
|
|
1416
|
-
return { error, index };
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
return undefined;
|
|
1420
|
-
}
|
|
1421
570
|
/**
|
|
1422
571
|
* Phase to enter once the tags/tools metadata prompts are resolved: org-scoped
|
|
1423
572
|
* targets get the reviewer prompt, everything else proceeds to the registry check.
|
|
@@ -1428,42 +577,6 @@ function phaseAfterMetadata(target) {
|
|
|
1428
577
|
function replaceEntry(entries, index, entry) {
|
|
1429
578
|
return entries.map((current, i) => (i === index ? entry : current));
|
|
1430
579
|
}
|
|
1431
|
-
/**
|
|
1432
|
-
* Folders next to the root asset that could fix the plan's error: an interactive
|
|
1433
|
-
* run, a `422` naming root dependencies that aren't in the registry (or have no
|
|
1434
|
-
* matching version), and a sibling folder whose manifest has the same type and
|
|
1435
|
-
* name that isn't an entry yet. Undefined when there is nothing to offer.
|
|
1436
|
-
* `--update` and `--from-installed` roots live in a temp folder, so they never get offers.
|
|
1437
|
-
*/
|
|
1438
|
-
async function suggestSiblingEntries(state, err) {
|
|
1439
|
-
const { target } = state;
|
|
1440
|
-
if (state.nonInteractive ||
|
|
1441
|
-
state.flags.fromInstalled ||
|
|
1442
|
-
state.flags.update ||
|
|
1443
|
-
target?.type !== 'asset' ||
|
|
1444
|
-
!(isApiError(err, 'dependency_not_found') || isApiError(err, 'dependency_version_unsatisfied'))) {
|
|
1445
|
-
return undefined;
|
|
1446
|
-
}
|
|
1447
|
-
const included = (d) => state.entries.some((e) => e.target.assetType === d.type && e.target.data.name === d.name);
|
|
1448
|
-
const missing = missingDependencies(target.data, err.issues).filter((d) => !included(d));
|
|
1449
|
-
if (missing.length === 0)
|
|
1450
|
-
return undefined;
|
|
1451
|
-
const rootDir = state.rawDetection?.sourceDir ?? target.sourceDir;
|
|
1452
|
-
const suggested = await findSiblingDependencies(rootDir, missing, state.entries.map((e) => e.target.sourceDir), target.data.org);
|
|
1453
|
-
return suggested.length > 0 ? { missing, suggested } : undefined;
|
|
1454
|
-
}
|
|
1455
|
-
/**
|
|
1456
|
-
* Build an ERROR dispatch action from a caught value, carrying the `code`,
|
|
1457
|
-
* `hint` and `command` typed errors have so commands can render actionable
|
|
1458
|
-
* recovery guidance.
|
|
1459
|
-
*/
|
|
1460
|
-
function toErrorAction(err, override) {
|
|
1461
|
-
return {
|
|
1462
|
-
...errorGuidance(err),
|
|
1463
|
-
message: override ?? errorMessage(err),
|
|
1464
|
-
type: 'ERROR',
|
|
1465
|
-
};
|
|
1466
|
-
}
|
|
1467
580
|
/** The entry published at `version`; a successful publish writes it to the entry's manifest. */
|
|
1468
581
|
function withVersion(entry, version) {
|
|
1469
582
|
return {
|
|
@@ -1472,52 +585,4 @@ function withVersion(entry, version) {
|
|
|
1472
585
|
target: { ...entry.target, data: { ...entry.target.data, version } },
|
|
1473
586
|
};
|
|
1474
587
|
}
|
|
1475
|
-
/**
|
|
1476
|
-
* Write what the run changed in memory to the manifests on disk: the root's bump and each
|
|
1477
|
-
* entry's, and the tags and tools the prompts asked for. Only once the publish has opened its
|
|
1478
|
-
* PR, so a cancelled or failed one leaves every manifest as it was. A file whose write fails
|
|
1479
|
-
* takes none of its later edits and comes back as one warning naming the fields it lacks; the
|
|
1480
|
-
* other files are still written.
|
|
1481
|
-
*/
|
|
1482
|
-
async function writeManifestEdits(state) {
|
|
1483
|
-
const target = state.target;
|
|
1484
|
-
if (!target)
|
|
1485
|
-
return [];
|
|
1486
|
-
const files = new Map();
|
|
1487
|
-
const edit = (dir, file, field, value, write) => {
|
|
1488
|
-
const path = join(dir, file);
|
|
1489
|
-
files.set(path, [...(files.get(path) ?? []), { field, value, write }]);
|
|
1490
|
-
};
|
|
1491
|
-
const version = (dir, file, value) => edit(dir, file, 'version', value, () => updateManifestVersion(dir, file, value));
|
|
1492
|
-
const field = (name, value) => edit(target.sourceDir, 'manifest.json', name, value, () => updateManifestField(target.sourceDir, 'manifest.json', name, value));
|
|
1493
|
-
if (state.versionCheck?.status === 'needs-bump' && state.resolvedVersion) {
|
|
1494
|
-
version(target.sourceDir, target.type === 'asset' ? 'manifest.json' : 'bundle.json', state.resolvedVersion);
|
|
1495
|
-
}
|
|
1496
|
-
// Only an asset's manifest.json takes the prompted tags and tools, as the target (and so the plan) has them.
|
|
1497
|
-
if (target.type === 'asset') {
|
|
1498
|
-
const { tags, tools } = target.data;
|
|
1499
|
-
if (state.promptTagsValue && tags && tags.length > 0) {
|
|
1500
|
-
field('tags', tags);
|
|
1501
|
-
}
|
|
1502
|
-
if (state.selectedPromptTools.length > 0) {
|
|
1503
|
-
field('tools', tools);
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
for (const entry of state.entries) {
|
|
1507
|
-
if (entry.versionCheck?.status === 'needs-bump' && entry.resolvedVersion) {
|
|
1508
|
-
version(entry.target.sourceDir, 'manifest.json', entry.resolvedVersion);
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
const unwritten = [];
|
|
1512
|
-
for (const [path, edits] of files) {
|
|
1513
|
-
const failed = await firstFailure(edits);
|
|
1514
|
-
if (!failed)
|
|
1515
|
-
continue;
|
|
1516
|
-
const missing = edits.slice(failed.index);
|
|
1517
|
-
// Only the version matters to the next publish: it asks for missing tags and tools again.
|
|
1518
|
-
const again = missing.some((e) => e.field === 'version') ? ' before you publish it again' : '';
|
|
1519
|
-
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}.`);
|
|
1520
|
-
}
|
|
1521
|
-
return unwritten;
|
|
1522
|
-
}
|
|
1523
588
|
//# sourceMappingURL=usePublishState.js.map
|