@aion0/forge 0.5.49 → 0.5.50
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/RELEASE_NOTES.md +48 -7
- package/app/api/craft-system/build/route.ts +78 -0
- package/app/api/craft-system/delete/route.ts +28 -0
- package/app/api/craft-system/helpers/file/route.ts +20 -0
- package/app/api/craft-system/helpers/openapi/route.ts +27 -0
- package/app/api/craft-system/helpers/shell/route.ts +26 -0
- package/app/api/craft-system/inject/route.ts +41 -0
- package/app/api/craft-system/kill-session/route.ts +19 -0
- package/app/api/craft-system/manifest/route.ts +71 -0
- package/app/api/craft-system/marketplace/install/route.ts +11 -0
- package/app/api/craft-system/marketplace/route.ts +18 -0
- package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
- package/app/api/craft-system/marketplace/update/route.ts +10 -0
- package/app/api/craft-system/marketplace/updates/route.ts +17 -0
- package/app/api/craft-system/publish/auto/route.ts +173 -0
- package/app/api/craft-system/publish/route.ts +50 -0
- package/app/api/craft-system/registry/route.ts +16 -0
- package/app/api/craft-system/runtime/react/route.ts +26 -0
- package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
- package/app/api/craft-system/runtime/sdk/route.ts +18 -0
- package/app/api/craft-system/scaffold/route.ts +164 -0
- package/app/api/craft-system/sessions/route.ts +45 -0
- package/app/api/craft-system/storage/route.ts +44 -0
- package/app/api/craft-system/tmux-sessions/route.ts +62 -0
- package/app/api/craft-system/ui/route.ts +30 -0
- package/app/api/crafts/[name]/[...route]/route.ts +48 -0
- package/app/api/crafts/route.ts +29 -0
- package/components/CraftBuilder.tsx +241 -0
- package/components/CraftManifestEditor.tsx +258 -0
- package/components/CraftMarketplaceModal.tsx +207 -0
- package/components/CraftPublishModal.tsx +285 -0
- package/components/CraftTabs.tsx +279 -0
- package/components/CraftTerminal.tsx +305 -0
- package/components/CraftTerminalPicker.tsx +179 -0
- package/components/CraftsDropdown.tsx +186 -0
- package/components/CraftsMarketplacePanel.tsx +194 -0
- package/components/ProjectDetail.tsx +105 -1
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +49 -1
- package/lib/craft-sdk/client.tsx +260 -0
- package/lib/craft-sdk/server.ts +14 -0
- package/lib/crafts/loader.ts +117 -0
- package/lib/crafts/registry.ts +272 -0
- package/lib/crafts/runtime.ts +208 -0
- package/lib/crafts/types.ts +92 -0
- package/lib/forge-skills/craft-builder.md +231 -0
- package/lib/help-docs/15-crafts.md +127 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
interface MarketItem {
|
|
6
|
+
name: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
version: string;
|
|
10
|
+
author?: string;
|
|
11
|
+
tags?: string[];
|
|
12
|
+
requires?: { hasFile?: string[]; hasGlob?: string[] };
|
|
13
|
+
installed: boolean;
|
|
14
|
+
installedVersion?: string;
|
|
15
|
+
hasUpdate: boolean;
|
|
16
|
+
compatible: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function CraftMarketplaceModal({ projectPath, onClose, onInstalled }: {
|
|
20
|
+
projectPath: string;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
onInstalled: () => void;
|
|
23
|
+
}) {
|
|
24
|
+
const [items, setItems] = useState<MarketItem[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
28
|
+
const [search, setSearch] = useState('');
|
|
29
|
+
const [filter, setFilter] = useState<'all' | 'compatible' | 'installed'>('compatible');
|
|
30
|
+
|
|
31
|
+
const refresh = async (force = false) => {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`/api/craft-system/marketplace?projectPath=${encodeURIComponent(projectPath)}${force ? '&refresh=1' : ''}`);
|
|
36
|
+
const j = await res.json();
|
|
37
|
+
if (!res.ok) throw new Error(j.error || `${res.status}`);
|
|
38
|
+
setItems(j.items || []);
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
setError(e?.message || String(e));
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Always force-refresh on initial open so users see what's actually on the
|
|
47
|
+
// remote (the server-side cache is short, but a freshly published craft
|
|
48
|
+
// could otherwise sit hidden for the cache TTL).
|
|
49
|
+
useEffect(() => { refresh(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
50
|
+
|
|
51
|
+
const filtered = useMemo(() => {
|
|
52
|
+
const q = search.trim().toLowerCase();
|
|
53
|
+
return items.filter(it => {
|
|
54
|
+
if (filter === 'compatible' && !it.compatible) return false;
|
|
55
|
+
if (filter === 'installed' && !it.installed) return false;
|
|
56
|
+
if (q && !`${it.name} ${it.displayName} ${it.description || ''} ${(it.tags || []).join(' ')}`.toLowerCase().includes(q)) return false;
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
}, [items, search, filter]);
|
|
60
|
+
|
|
61
|
+
const install = async (name: string) => {
|
|
62
|
+
setBusyId(name);
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/api/craft-system/marketplace/install', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ projectPath, name }),
|
|
68
|
+
});
|
|
69
|
+
const j = await res.json();
|
|
70
|
+
if (!j.ok) throw new Error(j.error || 'install failed');
|
|
71
|
+
onInstalled();
|
|
72
|
+
await refresh();
|
|
73
|
+
} catch (e: any) {
|
|
74
|
+
setError(e?.message || String(e));
|
|
75
|
+
} finally {
|
|
76
|
+
setBusyId(null);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const uninstall = async (name: string) => {
|
|
81
|
+
if (!confirm(`Uninstall craft "${name}"? Files at .forge/crafts/${name}/ will be deleted.`)) return;
|
|
82
|
+
setBusyId(name);
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch('/api/craft-system/marketplace/uninstall', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ projectPath, name }),
|
|
88
|
+
});
|
|
89
|
+
const j = await res.json();
|
|
90
|
+
if (!j.ok) throw new Error(j.error || 'uninstall failed');
|
|
91
|
+
onInstalled();
|
|
92
|
+
await refresh();
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
setError(e?.message || String(e));
|
|
95
|
+
} finally {
|
|
96
|
+
setBusyId(null);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
102
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-2xl w-[800px] max-w-[95vw] h-[80vh] flex flex-col"
|
|
103
|
+
onClick={e => e.stopPropagation()}>
|
|
104
|
+
<div className="px-4 py-2.5 border-b border-[var(--border)] flex items-center gap-2">
|
|
105
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">🛒 Crafts Marketplace</span>
|
|
106
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{items.length} craft{items.length === 1 ? '' : 's'} in registry</span>
|
|
107
|
+
<div className="flex-1" />
|
|
108
|
+
<button onClick={() => refresh(true)}
|
|
109
|
+
className="text-[10px] px-2 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
|
110
|
+
title="Re-fetch the registry">↻</button>
|
|
111
|
+
<button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Filter bar */}
|
|
115
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-2 text-xs">
|
|
116
|
+
<input
|
|
117
|
+
value={search} onChange={e => setSearch(e.target.value)}
|
|
118
|
+
placeholder="Search name / description / tag…"
|
|
119
|
+
className="flex-1 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1"
|
|
120
|
+
/>
|
|
121
|
+
<select value={filter} onChange={e => setFilter(e.target.value as any)}
|
|
122
|
+
className="text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1">
|
|
123
|
+
<option value="compatible">Compatible</option>
|
|
124
|
+
<option value="all">All</option>
|
|
125
|
+
<option value="installed">Installed</option>
|
|
126
|
+
</select>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{error && (
|
|
130
|
+
<div className="px-4 py-2 text-[11px] text-red-300 bg-red-500/10 border-b border-red-500/30">
|
|
131
|
+
{error}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{/* List */}
|
|
136
|
+
<div className="flex-1 overflow-auto">
|
|
137
|
+
{loading && <div className="p-4 text-xs text-[var(--text-secondary)]">Loading marketplace…</div>}
|
|
138
|
+
{!loading && filtered.length === 0 && (
|
|
139
|
+
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">
|
|
140
|
+
{items.length === 0
|
|
141
|
+
? 'No crafts in the registry yet. Configure `craftsRepoUrl` in settings to point at your own.'
|
|
142
|
+
: 'No crafts match your filter.'}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
{filtered.map(it => (
|
|
146
|
+
<div key={it.name} className="px-4 py-2.5 border-b border-[var(--border)]/50 flex items-start gap-3">
|
|
147
|
+
<div className="flex-1 min-w-0">
|
|
148
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
149
|
+
<span className="text-[12px] font-semibold text-[var(--text-primary)]">{it.displayName}</span>
|
|
150
|
+
<span className="text-[9px] font-mono text-[var(--text-secondary)]">{it.name}</span>
|
|
151
|
+
<span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">v{it.version}</span>
|
|
152
|
+
{it.author && <span className="text-[9px] text-[var(--text-secondary)]">by {it.author}</span>}
|
|
153
|
+
{it.installed && <span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300">installed{it.installedVersion ? ` v${it.installedVersion}` : ''}</span>}
|
|
154
|
+
{it.hasUpdate && <span className="text-[9px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-300">update available</span>}
|
|
155
|
+
{!it.compatible && <span className="text-[9px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-300" title={JSON.stringify(it.requires)}>incompatible with this project</span>}
|
|
156
|
+
</div>
|
|
157
|
+
{it.description && <div className="text-[11px] text-[var(--text-secondary)] mt-0.5">{it.description}</div>}
|
|
158
|
+
{it.tags && it.tags.length > 0 && (
|
|
159
|
+
<div className="flex gap-1 mt-1 flex-wrap">
|
|
160
|
+
{it.tags.map(t => (
|
|
161
|
+
<span key={t} className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">{t}</span>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
<div className="flex flex-col gap-1 shrink-0">
|
|
167
|
+
{!it.installed && (
|
|
168
|
+
<button onClick={() => install(it.name)} disabled={busyId === it.name || !it.compatible}
|
|
169
|
+
className="text-[10px] px-2.5 py-1 rounded bg-[var(--accent)]/30 text-[var(--accent)] hover:bg-[var(--accent)]/40 disabled:opacity-40">
|
|
170
|
+
{busyId === it.name ? '…' : 'Install'}
|
|
171
|
+
</button>
|
|
172
|
+
)}
|
|
173
|
+
{it.installed && it.hasUpdate && (
|
|
174
|
+
<button onClick={async () => {
|
|
175
|
+
setBusyId(it.name);
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch('/api/craft-system/marketplace/update', {
|
|
178
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
179
|
+
body: JSON.stringify({ projectPath, name: it.name }),
|
|
180
|
+
});
|
|
181
|
+
const j = await res.json();
|
|
182
|
+
if (!j.ok) throw new Error(j.error || 'update failed');
|
|
183
|
+
onInstalled();
|
|
184
|
+
await refresh();
|
|
185
|
+
} catch (e: any) { setError(e?.message || String(e)); }
|
|
186
|
+
finally { setBusyId(null); }
|
|
187
|
+
}}
|
|
188
|
+
disabled={busyId === it.name}
|
|
189
|
+
className="text-[10px] px-2.5 py-1 rounded bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30 disabled:opacity-40"
|
|
190
|
+
title={`Update v${it.installedVersion} → v${it.version}, preserves your data/`}>
|
|
191
|
+
{busyId === it.name ? '…' : `Update → v${it.version}`}
|
|
192
|
+
</button>
|
|
193
|
+
)}
|
|
194
|
+
{it.installed && (
|
|
195
|
+
<button onClick={() => uninstall(it.name)} disabled={busyId === it.name}
|
|
196
|
+
className="text-[10px] px-2.5 py-1 rounded text-red-300 hover:bg-red-500/10 disabled:opacity-40">
|
|
197
|
+
Uninstall
|
|
198
|
+
</button>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface PublishBundle {
|
|
6
|
+
entry: any;
|
|
7
|
+
files: { path: string; content: string }[];
|
|
8
|
+
fileLinks?: { path: string; githubUrl: string }[];
|
|
9
|
+
repo?: { owner: string; name: string; url: string };
|
|
10
|
+
registryEditUrl?: string;
|
|
11
|
+
instructions: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CraftManifestEditorLazy = React.lazy(() => import('./CraftManifestEditor'));
|
|
15
|
+
|
|
16
|
+
export default function CraftPublishModal({ projectPath, craftName, onClose }: {
|
|
17
|
+
projectPath: string;
|
|
18
|
+
craftName: string;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
}) {
|
|
21
|
+
const [editingManifest, setEditingManifest] = useState(false);
|
|
22
|
+
const [bundle, setBundle] = useState<PublishBundle | null>(null);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [tab, setTab] = useState<'instructions' | 'entry' | 'files'>('instructions');
|
|
25
|
+
const [activeFile, setActiveFile] = useState<string>('');
|
|
26
|
+
const [gh, setGh] = useState<{ available: boolean; user?: string } | null>(null);
|
|
27
|
+
const [autoState, setAutoState] = useState<'idle' | 'running' | 'done' | 'failed'>('idle');
|
|
28
|
+
const [autoLog, setAutoLog] = useState<string[]>([]);
|
|
29
|
+
const [prUrl, setPrUrl] = useState<string | null>(null);
|
|
30
|
+
const [autoError, setAutoError] = useState<string | null>(null);
|
|
31
|
+
|
|
32
|
+
// Probe whether gh CLI is usable for one-click publish
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
fetch('/api/craft-system/publish/auto')
|
|
35
|
+
.then(r => r.ok ? r.json() : { available: false })
|
|
36
|
+
.then(setGh)
|
|
37
|
+
.catch(() => setGh({ available: false }));
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const oneClick = async () => {
|
|
41
|
+
setAutoState('running');
|
|
42
|
+
setAutoLog([]);
|
|
43
|
+
setAutoError(null);
|
|
44
|
+
setPrUrl(null);
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch('/api/craft-system/publish/auto', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ projectPath, name: craftName }),
|
|
50
|
+
});
|
|
51
|
+
const j = await res.json();
|
|
52
|
+
if (!j.ok) {
|
|
53
|
+
setAutoError(j.error || 'failed');
|
|
54
|
+
setAutoLog(j.log || []);
|
|
55
|
+
setAutoState('failed');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setPrUrl(j.prUrl);
|
|
59
|
+
setAutoLog(j.log || []);
|
|
60
|
+
setAutoState('done');
|
|
61
|
+
} catch (e: any) {
|
|
62
|
+
setAutoError(e?.message || String(e));
|
|
63
|
+
setAutoState('failed');
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
fetch('/api/craft-system/publish', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ projectPath, name: craftName }),
|
|
72
|
+
})
|
|
73
|
+
.then(async r => { if (!r.ok) throw new Error((await r.json()).error || `${r.status}`); return r.json(); })
|
|
74
|
+
.then((j: PublishBundle) => {
|
|
75
|
+
setBundle(j);
|
|
76
|
+
setActiveFile(j.files[0]?.path || '');
|
|
77
|
+
})
|
|
78
|
+
.catch(e => setError(e?.message || String(e)));
|
|
79
|
+
}, [projectPath, craftName]);
|
|
80
|
+
|
|
81
|
+
const copy = (text: string) => navigator.clipboard.writeText(text).catch(() => {});
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
85
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-2xl w-[820px] max-w-[95vw] h-[80vh] flex flex-col"
|
|
86
|
+
onClick={e => e.stopPropagation()}>
|
|
87
|
+
<div className="px-4 py-2.5 border-b border-[var(--border)] flex items-center gap-2">
|
|
88
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">📦 Publish craft: {craftName}</span>
|
|
89
|
+
{bundle?.entry?.version && <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] font-mono text-[var(--text-secondary)]">v{bundle.entry.version}</span>}
|
|
90
|
+
<button onClick={() => setEditingManifest(true)}
|
|
91
|
+
className="text-[10px] px-2 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--accent)]"
|
|
92
|
+
title="Edit craft.yaml — bump version, tweak metadata, etc.">
|
|
93
|
+
📝 Edit manifest
|
|
94
|
+
</button>
|
|
95
|
+
<div className="flex-1" />
|
|
96
|
+
<button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{error && <div className="m-4 p-2 text-xs text-red-300 bg-red-500/10 border border-red-500/30 rounded">{error}</div>}
|
|
100
|
+
{!bundle && !error && <div className="p-4 text-xs text-[var(--text-secondary)]">Bundling craft files…</div>}
|
|
101
|
+
|
|
102
|
+
{bundle && (
|
|
103
|
+
<>
|
|
104
|
+
<div className="px-4 pt-2 flex gap-1 text-xs border-b border-[var(--border)]">
|
|
105
|
+
{(['instructions', 'entry', 'files'] as const).map(t => (
|
|
106
|
+
<button key={t} onClick={() => setTab(t)}
|
|
107
|
+
className={`px-3 py-1 rounded-t ${tab === t ? 'bg-[var(--bg-tertiary)] text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}>
|
|
108
|
+
{t === 'instructions' ? 'How to publish' : t === 'entry' ? 'registry.json entry' : `Files (${bundle.files.length})`}
|
|
109
|
+
</button>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{tab === 'instructions' && (
|
|
114
|
+
<div className="flex-1 overflow-auto p-4 text-[11px] text-[var(--text-primary)] space-y-3">
|
|
115
|
+
<div className="text-[var(--text-secondary)]">
|
|
116
|
+
Every publish goes through a pull request to{' '}
|
|
117
|
+
{bundle.repo
|
|
118
|
+
? <a href={bundle.repo.url} target="_blank" rel="noreferrer" className="text-[var(--accent)] hover:underline">{bundle.repo.owner}/{bundle.repo.name}</a>
|
|
119
|
+
: 'the registry'} — GitHub auto-forks the repo if you don't have write access. Maintainers also use the PR flow; direct commits to main are not accepted.
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* One-click PR via gh CLI */}
|
|
123
|
+
<div className="rounded border border-[var(--accent)]/40 bg-[var(--accent)]/5 p-3 space-y-2">
|
|
124
|
+
<div className="flex items-center gap-2">
|
|
125
|
+
<span className="text-[12px] font-semibold text-[var(--text-primary)]">🚀 One-click publish</span>
|
|
126
|
+
{gh?.available && <span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300">gh CLI ready as @{gh.user}</span>}
|
|
127
|
+
{gh && !gh.available && <span className="text-[9px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-300">gh CLI unavailable</span>}
|
|
128
|
+
</div>
|
|
129
|
+
{gh?.available ? (
|
|
130
|
+
<>
|
|
131
|
+
<div className="text-[10px] text-[var(--text-secondary)]">
|
|
132
|
+
Forge will fork {bundle.repo?.owner}/{bundle.repo?.name}, push the craft + registry update on a new branch, and open a PR — all from your authenticated <code>gh</code> CLI.
|
|
133
|
+
</div>
|
|
134
|
+
<div className="flex items-center gap-2">
|
|
135
|
+
<button onClick={oneClick} disabled={autoState === 'running' || autoState === 'done'}
|
|
136
|
+
className="text-[11px] px-3 py-1.5 rounded bg-[var(--accent)]/30 text-[var(--accent)] hover:bg-[var(--accent)]/40 disabled:opacity-40">
|
|
137
|
+
{autoState === 'running' ? '⏳ Publishing…'
|
|
138
|
+
: autoState === 'done' ? '✓ Submitted'
|
|
139
|
+
: autoState === 'failed' ? 'Retry'
|
|
140
|
+
: 'Submit PR via gh'}
|
|
141
|
+
</button>
|
|
142
|
+
{prUrl && (
|
|
143
|
+
<a href={prUrl} target="_blank" rel="noreferrer"
|
|
144
|
+
className="text-[11px] px-3 py-1.5 rounded bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30">
|
|
145
|
+
Open PR →
|
|
146
|
+
</a>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
{autoLog.length > 0 && (
|
|
150
|
+
<div className="bg-black/30 rounded p-2 text-[9px] font-mono text-[var(--text-secondary)] max-h-32 overflow-auto space-y-0.5">
|
|
151
|
+
{autoLog.map((l, i) => <div key={i}>· {l}</div>)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{autoError && (
|
|
155
|
+
<div className="text-[10px] text-red-300 bg-red-500/10 rounded p-2 break-words">
|
|
156
|
+
{autoError}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</>
|
|
160
|
+
) : (
|
|
161
|
+
<div className="text-[10px] text-[var(--text-secondary)]">
|
|
162
|
+
To enable one-click publish, install + authenticate <code>gh</code> in a terminal:
|
|
163
|
+
<pre className="mt-1 bg-black/30 rounded p-1.5 font-mono text-[var(--text-primary)]">brew install gh{'\n'}gh auth login</pre>
|
|
164
|
+
Then refresh this dialog. Until then, use the manual steps below.
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-3 mb-1">Or do it manually:</div>
|
|
170
|
+
<ol className="list-decimal pl-5 space-y-1.5">
|
|
171
|
+
{bundle.instructions.map((line, i) => <li key={i}>{line}</li>)}
|
|
172
|
+
</ol>
|
|
173
|
+
|
|
174
|
+
{bundle.fileLinks && bundle.fileLinks.length > 0 && (
|
|
175
|
+
<div className="mt-3 border border-[var(--border)] rounded p-2.5 space-y-1.5">
|
|
176
|
+
<div className="text-[10px] text-[var(--text-secondary)] mb-1">Step 1: Create each file in your fork (one click each)</div>
|
|
177
|
+
{bundle.fileLinks.map(fl => (
|
|
178
|
+
<a key={fl.path} href={fl.githubUrl} target="_blank" rel="noreferrer"
|
|
179
|
+
className="flex items-center gap-2 px-2 py-1.5 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--accent)]/10 border border-[var(--border)] transition-colors">
|
|
180
|
+
<span className="text-[11px] font-mono text-[var(--text-primary)] flex-1">
|
|
181
|
+
{bundle.entry.name}/{fl.path}
|
|
182
|
+
</span>
|
|
183
|
+
<span className="text-[10px] px-2 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]">
|
|
184
|
+
Open in GitHub →
|
|
185
|
+
</span>
|
|
186
|
+
</a>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{bundle.registryEditUrl && (
|
|
192
|
+
<div className="border border-[var(--border)] rounded p-2.5 space-y-1.5">
|
|
193
|
+
<div className="text-[10px] text-[var(--text-secondary)] mb-1">Step 2: Append your craft to registry.json</div>
|
|
194
|
+
<a href={bundle.registryEditUrl} target="_blank" rel="noreferrer"
|
|
195
|
+
className="flex items-center gap-2 px-2 py-1.5 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--accent)]/10 border border-[var(--border)] transition-colors">
|
|
196
|
+
<span className="text-[11px] font-mono text-[var(--text-primary)] flex-1">registry.json</span>
|
|
197
|
+
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); copy(JSON.stringify(bundle.entry, null, 2)); }}
|
|
198
|
+
className="text-[10px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
|
199
|
+
title="Copy entry JSON to clipboard before opening editor">
|
|
200
|
+
📋 Copy entry
|
|
201
|
+
</button>
|
|
202
|
+
<span className="text-[10px] px-2 py-0.5 rounded bg-[var(--accent)]/20 text-[var(--accent)]">
|
|
203
|
+
Open editor →
|
|
204
|
+
</span>
|
|
205
|
+
</a>
|
|
206
|
+
<div className="text-[9px] text-[var(--text-secondary)] opacity-70">
|
|
207
|
+
In the editor, paste the entry inside the <code>crafts: [...]</code> array, then commit + open PR.
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
<div className="mt-3 text-[var(--text-secondary)] text-[10px] opacity-70">
|
|
213
|
+
Need an alternative? Use the Files tab to copy each file's content manually, or the registry.json entry tab to copy the JSON snippet.
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{tab === 'entry' && (
|
|
219
|
+
<div className="flex-1 overflow-auto p-4 flex flex-col">
|
|
220
|
+
<div className="flex items-center mb-2">
|
|
221
|
+
<span className="text-[10px] text-[var(--text-secondary)]">Append this object to the <code>crafts</code> array in <code>registry.json</code>:</span>
|
|
222
|
+
<div className="flex-1" />
|
|
223
|
+
<button onClick={() => copy(JSON.stringify(bundle.entry, null, 2))}
|
|
224
|
+
className="text-[10px] px-2 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">📋 Copy</button>
|
|
225
|
+
</div>
|
|
226
|
+
<pre className="flex-1 overflow-auto bg-[var(--bg-tertiary)]/40 rounded p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap">
|
|
227
|
+
{JSON.stringify(bundle.entry, null, 2)}
|
|
228
|
+
</pre>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{tab === 'files' && (
|
|
233
|
+
<div className="flex-1 flex min-h-0">
|
|
234
|
+
<div className="w-48 border-r border-[var(--border)] overflow-auto">
|
|
235
|
+
{bundle.files.map(f => (
|
|
236
|
+
<button key={f.path} onClick={() => setActiveFile(f.path)}
|
|
237
|
+
className={`w-full text-left px-3 py-1.5 text-[11px] font-mono ${activeFile === f.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'}`}>
|
|
238
|
+
{f.path}
|
|
239
|
+
</button>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
<div className="flex-1 flex flex-col">
|
|
243
|
+
<div className="px-3 py-1.5 border-b border-[var(--border)] flex items-center text-[10px] text-[var(--text-secondary)]">
|
|
244
|
+
<span className="font-mono">{activeFile}</span>
|
|
245
|
+
<div className="flex-1" />
|
|
246
|
+
<button onClick={() => copy(bundle.files.find(f => f.path === activeFile)?.content || '')}
|
|
247
|
+
className="px-2 py-0.5 rounded hover:bg-[var(--bg-tertiary)]">📋 Copy</button>
|
|
248
|
+
</div>
|
|
249
|
+
<pre className="flex-1 overflow-auto p-3 text-[11px] font-mono text-[var(--text-primary)] whitespace-pre-wrap break-words">
|
|
250
|
+
{bundle.files.find(f => f.path === activeFile)?.content}
|
|
251
|
+
</pre>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Manifest editor mounted on top — saving re-fetches the bundle so the publish flow uses fresh values */}
|
|
260
|
+
{editingManifest && (
|
|
261
|
+
<React.Suspense fallback={null}>
|
|
262
|
+
<CraftManifestEditorLazy
|
|
263
|
+
projectPath={projectPath}
|
|
264
|
+
craftName={craftName}
|
|
265
|
+
onClose={() => setEditingManifest(false)}
|
|
266
|
+
onSaved={() => {
|
|
267
|
+
setEditingManifest(false);
|
|
268
|
+
// Re-fetch the bundle so the new version/metadata appears in publish UI + auto-publish
|
|
269
|
+
setBundle(null);
|
|
270
|
+
setError(null);
|
|
271
|
+
fetch('/api/craft-system/publish', {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({ projectPath, name: craftName }),
|
|
275
|
+
})
|
|
276
|
+
.then(async r => { if (!r.ok) throw new Error((await r.json()).error || `${r.status}`); return r.json(); })
|
|
277
|
+
.then((j: PublishBundle) => { setBundle(j); setActiveFile(j.files[0]?.path || ''); })
|
|
278
|
+
.catch(e => setError(e?.message || String(e)));
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
</React.Suspense>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|