diff --git a/frontend/app.py b/frontend/app.py
index 58bd49f..9f2741e 100644
--- a/frontend/app.py
+++ b/frontend/app.py
@@ -1270,6 +1270,10 @@ async def index():
let wzType = null; // 'code' | 'package' | 'repository'
let wzStep = 'type';
let wzIntrospectedTools = []; // tools returned by introspect
+let wzRepoCtx = null; // repository-wizard state carried across steps
+ // {name, command, repo_url, repo_ref,
+ // build_commands, workdir, env_keys, tools,
+ // buildOk, buildErr, introErr}
let providersMeta = {}; // name → {missing_secrets, validation_errors}
let knownFunctions = []; // names available in the current provider:
// code provider → async def names in the code block
@@ -2063,7 +2067,7 @@ async def index():
}
function openWizard() {
- wzType = null; wzStep = 'type'; wzIntrospectedTools = [];
+ wzType = null; wzStep = 'type'; wzIntrospectedTools = []; wzRepoCtx = null;
document.getElementById('wz-pkg-name').value = '';
document.getElementById('wz-pkg-cmd').value = '';
document.getElementById('wz-pkg-reqs-container').innerHTML = '';
@@ -2170,87 +2174,55 @@ async def index():
if (!cmd) { errEl.textContent = 'Spawn command is required.'; return; }
const build_commands = _wzGetListValues('wz-repo-builds-container');
const nextBtn = document.getElementById('wz-next-btn');
- nextBtn.disabled = true;
const origText = nextBtn.textContent;
const resultEl = document.getElementById('wz-repo-result');
- let workdir = '', env_keys = [], buildFailed = false;
+ nextBtn.disabled = true;
+
+ let result;
try {
nextBtn.textContent = '⏳ Cloning & building…';
resultEl.innerHTML = 'Cloning repo and running build commands — this may take a while…';
- const cb = await api('POST', '/api/clone-and-build', {
- name, repo_url: url, ref, build_commands,
- });
- workdir = cb.workdir || '';
- env_keys = cb.env_keys || [];
- if (!cb.ok) {
- // Build failure is tolerated — we still have a workdir and the
- // env_keys discovered from .env.example. Surface the error and
- // let the user continue to the Secrets step.
- buildFailed = true;
- resultEl.innerHTML = `
⚠ Build failed: ${esc(cb.error || '')}${cb.failed_command ? ` (running ${esc(cb.failed_command)})` : ''}.
`;
- if (env_keys.length) {
- resultEl.innerHTML += `Discovered ${env_keys.length} env key(s) from .env.example. Fill them in on the next step and the build will run again on the next server restart.
`;
- }
- } else {
- resultEl.innerHTML = `✓ Built in ${esc(workdir)}${env_keys.length ? ` · Discovered ${env_keys.length} env key(s) from .env.example` : ''}
`;
- nextBtn.textContent = '⏳ Introspecting…';
- const ir = await api('POST', '/api/introspect', {
- command: cmd, cwd: workdir, env_keys,
- });
- if (!ir.ok) {
- resultEl.innerHTML += `⚠ Introspection failed (${esc(ir.error||'')}). Continuing — add tools manually in the editor.
`;
- wzIntrospectedTools = [];
- } else {
- wzIntrospectedTools = ir.tools || [];
- resultEl.innerHTML += `✓ Found ${wzIntrospectedTools.length} tool(s)
`;
- }
- }
+ result = await _wzRepoBuildAndIntrospect({name, url, ref, build_commands, cmd, nextBtn});
} catch (e) {
errEl.textContent = e.message;
resultEl.innerHTML = `✗ ${esc(e.message)}
`;
- nextBtn.disabled = false;
- nextBtn.textContent = origText;
+ nextBtn.disabled = false; nextBtn.textContent = origText;
return;
} finally {
- nextBtn.disabled = false;
- nextBtn.textContent = origText;
+ nextBtn.disabled = false; nextBtn.textContent = origText;
}
- // Repository providers with a build failure may have no tools yet —
- // add a placeholder so the create-provider validation passes. Users
- // can replace it once secrets are populated and the next restart
- // builds successfully.
- let tools;
- if (wzIntrospectedTools.length) {
- tools = wzIntrospectedTools.map(t => ({
- name: t.name,
- function: '',
- description: t.description || '',
- documentation: '',
- enabled: true,
- parameters: _schemaToParams(t.inputSchema || t.input_schema || {}),
- secrets: [],
- }));
+
+ // Render the outcome summary
+ const lines = [];
+ if (result.ok) {
+ lines.push(`✓ Built in ${esc(result.workdir)}
`);
} else {
- tools = [{
- name: '_placeholder', function: '',
- description: 'Placeholder — re-introspect after the next successful build.',
- documentation: '', enabled: false, parameters: [], secrets: [],
- }];
+ lines.push(`⚠ Build failed: ${esc(result.buildErr || '')}${result.failed_command ? ` (running ${esc(result.failed_command)})` : ''}.
`);
}
- const provider = {
- name, type: 'repository', command: cmd, documentation: '', code: '',
- repo_url: url, repo_ref: ref, workdir,
- build_commands,
- repo_env_keys: env_keys,
- requirements: [], setup_commands: [],
- tools,
+ if (result.env_keys.length) {
+ lines.push(`Discovered ${result.env_keys.length} env key(s) from .env.example — fill them in next.
`);
+ }
+ if (result.tools.length) {
+ lines.push(`✓ Found ${result.tools.length} tool(s)
`);
+ } else if (result.introErr) {
+ lines.push(`⚠ Introspection failed (${esc(result.introErr)}).
`);
+ }
+ resultEl.innerHTML = lines.join('');
+
+ // Stash state — finalisation happens after the Secrets step (or
+ // immediately if no env_keys were discovered).
+ wzRepoCtx = {
+ name, command: cmd, repo_url: url, repo_ref: ref,
+ build_commands, ...result,
};
- try {
- const r = await api('POST', '/api/tools', {name, provider});
- currentName = name; currentProvider = provider;
- loadList();
- await wzGoSecrets(r.secret_keys || env_keys);
- } catch(e) { errEl.textContent = e.message; }
+
+ if (result.env_keys.length) {
+ // Defer provider creation until secrets are saved so we can re-run
+ // the build with .env in place.
+ await wzGoSecrets(result.env_keys);
+ } else {
+ await _wzRepoFinalize();
+ }
return;
}
@@ -2374,11 +2346,125 @@ async def index():
try { await api('POST', '/api/env', {vars}); toast(`Saved ${Object.keys(vars).length} secret(s) ✓`); }
catch(e) { toast(e.message, false); }
}
+
+ // Repository providers: with the secrets now in .env, retry the build
+ // (which writes /.env from os.environ) and re-introspect, then
+ // finalise. If the retry still fails we save anyway so the user can
+ // edit manually — much better than getting stuck on the wizard.
+ if (wzRepoCtx) {
+ const nextBtn = document.getElementById('wz-next-btn');
+ const origText = nextBtn.textContent;
+ nextBtn.disabled = true;
+ try {
+ nextBtn.textContent = '⏳ Re-building with secrets…';
+ const retry = await _wzRepoBuildAndIntrospect({
+ name: wzRepoCtx.name,
+ url: wzRepoCtx.repo_url,
+ ref: wzRepoCtx.repo_ref,
+ build_commands: wzRepoCtx.build_commands,
+ cmd: wzRepoCtx.command,
+ nextBtn,
+ });
+ Object.assign(wzRepoCtx, retry);
+ if (!retry.ok) {
+ toast(`Build still failing: ${retry.buildErr || ''}. Saving the provider anyway — use "↻ Re-clone & build" in the editor once you've fixed it.`, false);
+ } else if (retry.introErr) {
+ toast(`Build succeeded but introspection failed: ${retry.introErr}. Add tools manually in the editor.`, false);
+ } else if (retry.tools.length) {
+ toast(`Re-built ✓ · ${retry.tools.length} tool(s) introspected`);
+ }
+ } catch (e) {
+ toast(`Build retry failed: ${e.message}`, false);
+ } finally {
+ nextBtn.disabled = false; nextBtn.textContent = origText;
+ }
+ await _wzRepoFinalize();
+ return;
+ }
+
wizModal.hide();
await loadList();
await openProvider(currentName);
}
+// ── Repository wizard helpers ─────────────────────────────────────────────
+
+async function _wzRepoBuildAndIntrospect({name, url, ref, build_commands, cmd, nextBtn}) {
+ const cb = await api('POST', '/api/clone-and-build', {
+ name, repo_url: url, ref, build_commands,
+ });
+ const out = {
+ ok: !!cb.ok,
+ buildErr: cb.error || null,
+ failed_command: cb.failed_command || null,
+ workdir: cb.workdir || '',
+ env_keys: cb.env_keys || [],
+ tools: [],
+ introErr: null,
+ };
+ if (cb.ok && cmd) {
+ if (nextBtn) nextBtn.textContent = '⏳ Introspecting…';
+ try {
+ const ir = await api('POST', '/api/introspect', {
+ command: cmd, cwd: out.workdir, env_keys: out.env_keys,
+ });
+ if (ir.ok) out.tools = ir.tools || [];
+ else out.introErr = ir.error || 'introspection failed';
+ } catch (e) {
+ out.introErr = e.message;
+ }
+ }
+ return out;
+}
+
+// Build the provider object and PUT it (idempotent across retries).
+async function _wzRepoFinalize() {
+ const ctx = wzRepoCtx;
+ if (!ctx) return;
+ let tools;
+ if (ctx.tools && ctx.tools.length) {
+ tools = ctx.tools.map(t => ({
+ name: t.name,
+ function: '',
+ description: t.description || '',
+ documentation: '',
+ enabled: true,
+ parameters: _schemaToParams(t.inputSchema || t.input_schema || {}),
+ secrets: [],
+ }));
+ } else {
+ // No tools yet (build still failing or introspection failed). Insert a
+ // disabled placeholder so create/update validation passes; user can
+ // replace it from the editor after fixing the build.
+ tools = [{
+ name: '_placeholder', function: '',
+ description: 'Placeholder — re-introspect once the build succeeds.',
+ documentation: '', enabled: false, parameters: [], secrets: [],
+ }];
+ }
+ const provider = {
+ name: ctx.name, type: 'repository',
+ command: ctx.command, documentation: '', code: '',
+ repo_url: ctx.repo_url, repo_ref: ctx.repo_ref, workdir: ctx.workdir,
+ build_commands: ctx.build_commands,
+ repo_env_keys: ctx.env_keys,
+ requirements: [], setup_commands: [],
+ tools,
+ };
+ try {
+ // PUT is idempotent — creates if missing, replaces if present. This
+ // makes the wizard safe to retry without 409 collisions.
+ await api('PUT', `/api/tools/${ctx.name}`, {provider});
+ currentName = ctx.name; currentProvider = provider;
+ } catch (e) {
+ toast(e.message, false);
+ }
+ wzRepoCtx = null;
+ wizModal.hide();
+ await loadList();
+ if (currentName) await openProvider(currentName);
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index 09f933e..b71da06 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -456,6 +456,19 @@ def test_editor_has_repository_box(self, client):
assert "f-repo-url" in text
assert "build-commands-container" in text
+ def test_wizard_defers_provider_creation_until_secrets(self, client):
+ text = client.get("/").text
+ # New idempotent helpers must be present
+ assert "_wzRepoBuildAndIntrospect" in text
+ assert "_wzRepoFinalize" in text
+ assert "wzRepoCtx" in text
+
+ def test_wizard_uses_put_for_idempotent_create(self, client):
+ # _wzRepoFinalize must PUT to /api/tools/{name} so retries don't 409.
+ text = client.get("/").text
+ assert "PUT" in text
+ assert "/api/tools/${ctx.name}" in text or "/api/tools/" in text
+
def test_no_manual_introspect_button(self, client):
"""The 🔍 Introspect Tools button is replaced by auto-introspection."""
text = client.get("/").text