Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/hosted/web/src/contracts/dashboard-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AsyncResult, Atom } from "effect/reactivity";
import type { AppId } from "@executor-js/sdk";
import type { OrganizationReference } from "@executor-js/hosted-server/organization";
import { providerDisplayUrl, type InstallApp } from "@executor-js/ui/contracts/dashboard";
import { blankAppFiles } from "@executor-js/ui/contracts/blank-app";
import { HostedClient, catalogAtom } from "./api.ts";
import { inventoryAtom } from "./organization.ts";
import { acknowledgeApp, toolsAtom } from "./apps.ts";
Expand Down Expand Up @@ -53,4 +54,20 @@ export const dashboardAtoms = Atom.family((organization: OrganizationReference)
client.apps.importCustom({ params: { organization }, payload: { source: input } }),
).pipe(Effect.tap((saved) => Effect.sync(() => acknowledgeApp(get, organization, saved)))),
),
/**
* Hosted `apps.deploy` creates the named app, builds it, and stores the working source.
* `package.json` pins the release `framework.release` reports, which the build requires.
*/
createBlank: HostedClient.runtime.fn((input: { name: string }, get) =>
Effect.gen(function* () {
const client = yield* HostedClient;
const { version } = yield* client.framework.release({ params: { organization } });
const saved = yield* client.apps.deploy({
params: { organization },
payload: { name: input.name, files: blankAppFiles(input.name, version) },
});
yield* Effect.sync(() => acknowledgeApp(get, organization, saved));
return saved;
}),
),
}));
39 changes: 37 additions & 2 deletions apps/hosted/web/src/implementation/pages/add-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { useAtomMount } from "@effect/atom-react";
import { HostedFailure, useDashboardAtoms } from "../components/dashboard-bindings.tsx";
import { Link, useNavigate } from "@tanstack/react-router";
import { Button } from "@executor-js/ui/components/button";
import { CatalogPage as Catalog, CatalogInstall } from "@executor-js/ui/dashboard/catalog";
import {
BlankAppSetup,
CatalogPage as Catalog,
CatalogInstall,
} from "@executor-js/ui/dashboard/catalog";
import { InstallPublication } from "@executor-js/ui/dashboard/install-publication";
import { type AppAcknowledgement } from "@executor-js/ui/contracts/app-management";
import type { CatalogEntry } from "@executor-js/catalog/contracts";
Expand All @@ -19,7 +23,9 @@ import { acknowledgeApp } from "../../contracts/apps.ts";

type Selection =
| { readonly kind: "catalog"; readonly entry: CatalogEntry }
| { readonly kind: "publication"; readonly publication: typeof Publication.Type };
| { readonly kind: "publication"; readonly publication: typeof Publication.Type }
| { readonly kind: "blank" };

/** Public publications and integration templates share Add app and the same organization-owned app records. */
export function AddAppPage() {
const mcpOrigin = useMcpOrigin();
Expand Down Expand Up @@ -64,6 +70,24 @@ export function AddAppPage() {
onInstalled={installed}
/>
);
if (selection?.kind === "blank")
return (
<BlankAppSetup
mutation={atoms.createBlank}
Failure={HostedFailure}
onBack={() => {
reportBrowserUsage({ area: "apps", action: "blank_app", outcome: "cancelled" });
setSelection(undefined);
}}
onCreated={(app) =>
navigate({
to: "/org/$organizationSlug/apps/$appId",
search: { view: "source" },
params: { organizationSlug, appId: app.id },
})
}
/>
);
return (
<Catalog
query={atoms.catalog}
Expand All @@ -90,6 +114,17 @@ export function AddAppPage() {
}
action={
<div className="flex gap-2">
<Button
variant="outline"
data-product-area="apps"
data-product-action="blank_app"
onClick={() => {
reportBrowserUsage({ area: "apps", action: "blank_app", outcome: "started" });
setSelection({ kind: "blank" });
}}
>
Create blank app
</Button>
<Button variant="outline" asChild>
<Link
data-product-area="apps"
Expand Down
15 changes: 5 additions & 10 deletions packages/sdk/src/implementation/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,10 +638,8 @@ export const makeOAuth = (
: "manual";
// A client saved at a callback this host sent before is registered with that callback only.
// Executor can replace one it registered or read from its metadata document, so those sign
// in again at the current callback. Only a client someone entered, or one for a server that
// offers no other way, keeps sending the old callback, since only its owner can change what
// the provider allows. A record saved before sources were recorded counts as registered
// where the server registers clients.
// in again at the current callback. Only a client someone entered keeps sending the old
// callback, since only its owner can change what the provider allows.
const previous =
redirect === undefined
? []
Expand All @@ -662,12 +660,9 @@ export const makeOAuth = (
if (saved === null) continue;
const registered = yield* decrypt(id, saved.encrypted, OAuthRegistration);
const metadata = yield* decrypt(id, saved.encrypted, OAuthSavedClientMetadata);
if (
candidate !== redirect &&
metadata.executor_source !== "manual" &&
registration !== "manual"
)
continue;
// Previous callbacks: only reuse clients the operator entered. Anything Executor
// registered (or saved before sources existed) must sign in again at `redirect`.
if (candidate !== redirect && metadata.executor_source !== "manual") continue;
if (
registered.client_secret_expires_at !== undefined &&
registered.client_secret_expires_at !== 0 &&
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/contracts/blank-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** Files a blank app starts from. The version is the one `framework.release` returns. */
import { appSlug } from "@executor-js/sdk";

type SourceFile = { readonly path: string; readonly content: string };

const index = `import { defineApp, router } from "apps";

export default defineApp({ accounts: {} }, async () => ({
tools: router({}),
}));
`;

/** `index.ts` and a `package.json` that pins this host's `apps` release. */
export const blankAppFiles = (
name: string,
appsVersion: string,
): readonly [SourceFile, ...SourceFile[]] => [
{ path: "index.ts", content: index },
{
path: "package.json",
content: `${JSON.stringify(
{
name: appSlug(name),
private: true,
type: "module",
dependencies: { apps: appsVersion },
},
null,
2,
)}\n`,
},
];
45 changes: 44 additions & 1 deletion packages/ui/src/implementation/dashboard/catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AsyncResult } from "effect/reactivity";
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import { useState, type ReactNode } from "react";
import { Option } from "effect";
import type { DeployedApp } from "@executor-js/sdk";
import type { App, DeployedApp } from "@executor-js/sdk";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { quickAdd, type CatalogEntry } from "@executor-js/catalog/contracts";
Expand Down Expand Up @@ -191,6 +191,49 @@ export function CatalogPage<E, P>({
</div>
);
}
/** Name a new app, then the product creates it the way an agent does and opens its source. */
export function BlankAppSetup<E, A extends App>({
mutation,
Failure,
onCreated,
onBack,
}: MutationProps<{ readonly name: string }, A, E> & {
readonly onCreated: (app: A) => void | Promise<void>;
readonly onBack: () => void;
}) {
const result = useAtomValue(mutation);
return (
<div className="page setup-page w-full shrink-0 [padding:24px_24px_48px] my-0 mx-auto max-w-212.5 max-[1000px]:[padding:20px_20px_40px] max-[740px]:[padding:18px_max(16px,_env(safe-area-inset-right))_max(32px,_env(safe-area-inset-bottom))_max(16px,_env(safe-area-inset-left))]">
<Button
variant="ghost"
size="sm"
className="back-link h-auto rounded-none p-0 font-normal hover:bg-transparent inline-flex gap-1.5 items-center text-[12px] text-muted-foreground mb-4.25 hover:text-foreground max-[740px]:min-h-11 max-[740px]:inline-flex max-[740px]:items-center max-[740px]:-mt-2 max-[740px]:mb-3"
onClick={onBack}
disabled={result.waiting}
>
<HugeiconsIcon icon={ArrowLeft02Icon} strokeWidth={2} aria-hidden size={14} />
All apps
</Button>
<div className="page-heading gap-4 flex justify-between items-center min-h-12 mb-4.5 [&_p]:text-muted-foreground [&_p]:text-[13px] [&_p]:mt-1.25 [&_>_div]:min-w-0 [&_>_div]:wrap-anywhere max-[740px]:items-start max-[740px]:mb-4.5 max-[740px]:[&_p]:leading-[1.6] max-[740px]:[&_>_[data-slot='button']]:mt-0.25 max-[740px]:[.setup-page_&]:min-h-0">
<div>
<h1 className="text-[22px] font-semibold tracking-[-0.035em] leading-[1.35]">
Create blank app
</h1>
<p>Start from an empty app and build with your agent.</p>
</div>
</div>
<AppCreateForm
mutation={mutation}
Failure={Failure}
initialName=""
input={(name) => ({ name })}
onCreated={onCreated}
onCancel={onBack}
label="Create app"
/>
</div>
);
}
/**
* MCP servers and built-in apps are added directly; the server confirms how an MCP server
* connects. Every other service is set up by the user's agent from a copied prompt.
Expand Down