mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-11 05:31:24 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5639a26f | ||
|
|
7f8b7bf567 | ||
|
|
096e83a7bd | ||
|
|
89ce84cefe | ||
|
|
d5d2a64c32 |
No files matched your search
@@ -0,0 +1,34 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
npm-development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
cargo:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
Generated
+1
@@ -11228,6 +11228,7 @@ dependencies = [
|
||||
"md5 0.8.0",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/rauchg"><img src="https://github.com/rauchg.png" width="80px" alt="User avatar: rauchg" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
@@ -260,14 +260,17 @@ function LoadedImportDataDialog({
|
||||
|
||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
||||
|
||||
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
|
||||
// updates toggle together, while removals only ever cascade beneath a removed folder.
|
||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
||||
const targets = new Set(
|
||||
collectItems(node)
|
||||
.filter((i) => togglesWith(node.data, i))
|
||||
.map((i) => i.modelId),
|
||||
);
|
||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||
// to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
|
||||
const targets = new Set(togglableItems(node).map((i) => i.modelId));
|
||||
if (checked && node.data.kind === "item") {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data.item, byId)) {
|
||||
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
|
||||
}
|
||||
}
|
||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
||||
};
|
||||
|
||||
@@ -277,25 +280,16 @@ function LoadedImportDataDialog({
|
||||
);
|
||||
};
|
||||
|
||||
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
|
||||
// new folder can't exist, and a removed folder takes its contents with it.
|
||||
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null || parent.model !== "folder") break;
|
||||
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
|
||||
if (item.action !== "delete") continue;
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.action === "delete" && parent.selected) {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
@@ -314,18 +308,26 @@ function LoadedImportDataDialog({
|
||||
return item.selected;
|
||||
}).length;
|
||||
|
||||
const destinationLabel = (() => {
|
||||
if (plan.destination.type === "new_workspace") return "New workspace";
|
||||
// The row's label carries what kind of destination it is, so the value can just be its name
|
||||
const [destinationLabel, destinationValue] = ((): [string, string] => {
|
||||
if (plan.destination.type === "new_workspace") {
|
||||
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||
if (names.length === 0) return ["New workspace", "Untitled"];
|
||||
return [pluralize("New workspace", names.length), names.join(", ")];
|
||||
}
|
||||
const { workspaceId, folderId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name;
|
||||
return [
|
||||
"Destination",
|
||||
folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name,
|
||||
];
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
|
||||
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
|
||||
const planned = plan.resources.workspaces[0];
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
@@ -335,11 +337,9 @@ function LoadedImportDataDialog({
|
||||
return {
|
||||
key: existing?.id ?? planned?.id ?? "workspace",
|
||||
data: {
|
||||
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
|
||||
model: "workspace",
|
||||
modelId: existing?.id ?? planned?.id ?? "workspace",
|
||||
name: existing?.name ?? planned?.name ?? "New workspace",
|
||||
selected: true,
|
||||
kind: "destination",
|
||||
label: existing?.name ?? planned?.name ?? "New workspace",
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
@@ -349,40 +349,42 @@ function LoadedImportDataDialog({
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label="Destination" value={destinationLabel} />
|
||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||
</div>
|
||||
|
||||
{plan.warnings.map((warning) => (
|
||||
<Banner
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</Banner>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
||||
<CheckboxTree
|
||||
node={workspaceRoot}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
||||
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
|
||||
isRelevant={(n) =>
|
||||
n.data.kind === "destination" ||
|
||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||
}
|
||||
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{plan.warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Import details</div>
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
{plan.warnings.map((warning) => (
|
||||
<div
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||
>
|
||||
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack space={2} alignItems="center" className="mt-3">
|
||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
||||
<Button
|
||||
@@ -402,7 +404,7 @@ function LoadedImportDataDialog({
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Apply"}
|
||||
: "Done"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
item,
|
||||
row,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
item: ImportPlanItem;
|
||||
row: TreeRow;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
if (row.kind !== "item") {
|
||||
return (
|
||||
<>
|
||||
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
|
||||
<div className="truncate flex-1">{row.label}</div>
|
||||
{row.kind === "destination" && row.isNew && (
|
||||
<ActionChip label="new" help="Created by this import" className="text-success" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { item } = row;
|
||||
const label = actionLabel(item);
|
||||
return (
|
||||
<>
|
||||
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
||||
}
|
||||
/>
|
||||
{item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{item.name}</div>
|
||||
{item.action === "conflict" ? (
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl
|
||||
name={`conflict-${item.modelId}`}
|
||||
label={`Resolve conflict for ${item.name}`}
|
||||
hideLabel
|
||||
size="2xs"
|
||||
help={actionHelp(item)}
|
||||
value={item.resolution ?? "keep_mine"}
|
||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
||||
options={[
|
||||
@@ -577,29 +590,49 @@ function ImportTreeRow({
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
<IconTooltip content={actionHelp(item)} iconSize="sm" />
|
||||
</div>
|
||||
) : (
|
||||
actionLabel(item) && (
|
||||
<InlineCode
|
||||
label != null && (
|
||||
<ActionChip
|
||||
label={label}
|
||||
help={actionHelp(item)}
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
item.action === "create" && "text-success",
|
||||
item.action === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
item.action === "ignored" && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{actionLabel(item)}
|
||||
<IconTooltip content={actionHelp(item)} iconSize="xs" />
|
||||
</InlineCode>
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionChip({
|
||||
label,
|
||||
help,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
help: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||
</InlineCode>
|
||||
);
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
@@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null {
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "ignored":
|
||||
return "ignored";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionHelp(item: ImportPlanItem): string | null {
|
||||
const help = (text: string) =>
|
||||
item.changedFields.length > 0
|
||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
||||
: text;
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "Added since the last import";
|
||||
return "Not in this workspace yet";
|
||||
case "update":
|
||||
return "Changed since the last import";
|
||||
return item.reason === "moved_into_ignored_folder"
|
||||
? "Moved into a folder that isn't imported. Importing that folder brings it along"
|
||||
: help("Changed in the source since the last import");
|
||||
case "delete":
|
||||
return "Deleted since the last import";
|
||||
return "Gone from the source since the last import. Checking it deletes it here";
|
||||
case "keep_local":
|
||||
return "Local edits made since the last import. Importing will revert them if checked";
|
||||
return help("Changed here since the last import. Checking it reverts to the source");
|
||||
case "conflict":
|
||||
return "Changed both here and in the file since the last import";
|
||||
return help("Changed here and in the source since the last import");
|
||||
case "ignored":
|
||||
return "Not in this workspace. Imports leave it alone until you check it";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
||||
function fieldLabel(field: string): string {
|
||||
return field.replace(/([A-Z])/g, " $1").toLowerCase();
|
||||
}
|
||||
|
||||
/** Every plan item above `item`, nearest first. */
|
||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
||||
const ancestors: ImportPlanItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null) break;
|
||||
ancestors.push(parent);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
|
||||
* workspace's environments sit in are headings: they aggregate their children and decide nothing
|
||||
* themselves.
|
||||
*/
|
||||
type TreeRow =
|
||||
| { kind: "destination"; label: string; isNew: boolean }
|
||||
| { kind: "group"; label: string; icon: IconProps["icon"] }
|
||||
| { kind: "item"; item: ImportPlanItem };
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
const roots: ImportPlanItem[] = [];
|
||||
@@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
|
||||
}
|
||||
}
|
||||
|
||||
const foldersFirst = (list: ImportPlanItem[]) => [
|
||||
const byKind = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "environment"),
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "folder"),
|
||||
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
|
||||
key: item.modelId,
|
||||
data: item,
|
||||
data: { kind: "item", item },
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
return foldersFirst(roots).map((r) => toNode(r, new Set()));
|
||||
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
|
||||
// the base one, not its child — so a heading groups them into one thing to turn on and off.
|
||||
const environments = roots.filter((i) => i.model === "environment");
|
||||
const others = byKind(roots.filter((i) => i.model !== "environment"));
|
||||
const nodes = others.map((r) => toNode(r, new Set()));
|
||||
if (environments.length === 0) return nodes;
|
||||
return [
|
||||
{
|
||||
key: "group:environments",
|
||||
data: { kind: "group", label: "Variables", icon: "variable" },
|
||||
children: environments.map((e) => toNode(e, new Set())),
|
||||
},
|
||||
...nodes,
|
||||
];
|
||||
}
|
||||
|
||||
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
||||
return [node.data, ...node.children.flatMap(collectItems)];
|
||||
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
|
||||
return [node.data, ...node.children.flatMap(collectRows)];
|
||||
}
|
||||
|
||||
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
||||
function isMissingFolder(item: ImportPlanItem): boolean {
|
||||
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
|
||||
* (deletions, reverting local edits) never ride along with a parent toggle.
|
||||
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
|
||||
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
|
||||
* along with a parent's.
|
||||
*/
|
||||
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
|
||||
if (item.model === "workspace") return false;
|
||||
if (root.action === "delete") return item.action === "delete";
|
||||
if (item.action === "keep_local") {
|
||||
return root.modelId === item.modelId && item.model !== "folder";
|
||||
}
|
||||
return item.action === "create" || item.action === "update";
|
||||
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||
if (row.kind !== "item") return null;
|
||||
const { item } = row;
|
||||
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(
|
||||
node: CheckboxTreeNode<ImportPlanItem>,
|
||||
): boolean | "indeterminate" | "hidden" {
|
||||
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
|
||||
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
|
||||
return collectRows(node)
|
||||
.map(togglableItem)
|
||||
.filter((i) => i != null);
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
|
||||
const covered = togglableItems(node);
|
||||
if (covered.length === 0) return "hidden";
|
||||
const selected = covered.filter((i) => i.selected).length;
|
||||
if (selected === covered.length) return true;
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props<T> {
|
||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
|
||||
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
@@ -29,7 +31,9 @@ interface Props<T> {
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
||||
);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
import { Label } from "./Label";
|
||||
|
||||
interface Props<T extends string> {
|
||||
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
|
||||
// A visually hidden label has nowhere to show the help, so the last option carries it
|
||||
const inlineHelp =
|
||||
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
|
||||
|
||||
return (
|
||||
<div className="w-full grid">
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
help={help}
|
||||
help={hideLabel ? undefined : help}
|
||||
visuallyHidden={hideLabel}
|
||||
className={classNames(labelClassName)}
|
||||
>
|
||||
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((o) => {
|
||||
{options.map((o, i) => {
|
||||
const isSelected = selectedValue === o.value;
|
||||
const isActive = value === o.value;
|
||||
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
||||
if (o.icon == null) {
|
||||
return (
|
||||
<Button
|
||||
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
|
||||
isActive && "text-text!",
|
||||
"focus:ring-1 focus:ring-border-focus",
|
||||
)}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
|
||||
)}
|
||||
title={o.label}
|
||||
icon={o.icon}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
---
|
||||
name: use-yaak
|
||||
description: >
|
||||
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
|
||||
folders, environments and variables, author HTTP requests, configure
|
||||
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
|
||||
send them individually or a whole folder/workspace at once, chain one
|
||||
request's response into the next, and import existing APIs from OpenAPI,
|
||||
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
|
||||
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
|
||||
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
|
||||
API requests for reuse, to set up API requests for manual testing, to add auth
|
||||
to a saved request, to turn an OpenAPI or Postman collection into runnable
|
||||
requests, or to run a saved request suite against staging versus production.
|
||||
Prefer this over one-off `curl` commands whenever the requests should be
|
||||
saved, reused, shared, or run as a set.
|
||||
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
|
||||
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
|
||||
requests for reuse or manual testing, configure auth on saved requests, import
|
||||
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
|
||||
saved requests across environments. Prefer over one-off `curl` when requests
|
||||
should be saved, reused, shared, or run as a set.
|
||||
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
|
||||
---
|
||||
|
||||
@@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry.
|
||||
Two consequences worth holding onto. Requests you create are permanent user data
|
||||
in an app they use, not scratch files, so name them the way the user would and
|
||||
clean up anything created just to test. And because the app is right there, the
|
||||
CLI is usually the wrong place to *read* a response in detail; it is the right
|
||||
CLI is usually the wrong place to _read_ a response in detail; it is the right
|
||||
place to build, organize, and run requests.
|
||||
|
||||
## The CLI describes itself
|
||||
|
||||
@@ -68,7 +68,9 @@ async fn import(
|
||||
);
|
||||
}
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||
Some(workspace_id) => {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
|
||||
}
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let plan = import::plan_import_resources(
|
||||
@@ -113,6 +115,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
if keep_local > 0 {
|
||||
parts.push(format!("{keep_local} with local edits"));
|
||||
}
|
||||
let ignored = count(ImportPlanAction::Ignored);
|
||||
if ignored > 0 {
|
||||
parts.push(format!("{ignored} ignored"));
|
||||
}
|
||||
let unchanged = count(ImportPlanAction::Unchanged);
|
||||
if unchanged > 0 {
|
||||
parts.push(format!("{unchanged} unchanged"));
|
||||
|
||||
@@ -883,11 +883,8 @@ mod tests {
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("vendor")).expect("create vendor");
|
||||
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
@@ -24,6 +23,7 @@ use yaak_models::models::Environment;
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
|
||||
|
||||
@@ -4,6 +4,7 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
||||
use predicates::str::contains;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn export_writes_yaak_workspace_file() {
|
||||
@@ -209,7 +210,10 @@ fn re_import_merges_into_linked_workspace() {
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
|
||||
@@ -257,3 +261,62 @@ fn re_import_merges_into_linked_workspace() {
|
||||
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
|
||||
assert!(requests.iter().any(|r| r.name == "Request C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_import_leaves_deleted_resources_alone() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("linked.json");
|
||||
|
||||
write_linked_fixture(
|
||||
&import_path,
|
||||
&[
|
||||
("req_a", "Request A", "https://example.com/a"),
|
||||
("req_b", "Request B", "https://example.com/b"),
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let workspace_id = {
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let workspace_id = db
|
||||
.list_workspaces()
|
||||
.expect("list workspaces")
|
||||
.into_iter()
|
||||
.find(|w| w.name == "Linked Workspace")
|
||||
.expect("workspace imported")
|
||||
.id;
|
||||
let request_b = db
|
||||
.list_http_requests(&workspace_id)
|
||||
.expect("list requests")
|
||||
.into_iter()
|
||||
.find(|r| r.name == "Request B")
|
||||
.expect("request B imported");
|
||||
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync).expect("delete request B");
|
||||
workspace_id
|
||||
};
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
"--workspace-id",
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Skipped 1 ignored"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let requests =
|
||||
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
|
||||
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
|
||||
assert_eq!(requests[0].name, "Request A");
|
||||
}
|
||||
@@ -145,11 +145,8 @@ async fn cache_control(req: Request, next: Next) -> Response {
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/html"));
|
||||
let value = if hashed_name && !is_html {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
let value =
|
||||
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
|
||||
res
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
|
||||
entries
|
||||
}
|
||||
|
||||
|
||||
pub(crate) async fn build_metadata<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
|
||||
@@ -177,4 +177,3 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
|
||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||
}
|
||||
|
||||
@@ -29,16 +29,16 @@ use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::send::ResponseBody;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_commands::resolve::resolve_grpc_request;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_common::command::new_checked_command;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||
use yaak_grpc::{Code, ServiceDefinition};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType,
|
||||
HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
@@ -1033,20 +1033,6 @@ async fn cmd_commit_import<R: Runtime>(
|
||||
commit_import(&window, plan)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
|
||||
@@ -1141,7 +1127,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
url: &str,
|
||||
|
||||
@@ -16,8 +16,7 @@ use std::time::{Duration, Instant};
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::plugin::{Builder, TauriPlugin};
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent,
|
||||
is_dev,
|
||||
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, is_dev,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use ts_rs::TS;
|
||||
@@ -28,10 +27,10 @@ use yaak_plugins::api::{
|
||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||
search_plugins,
|
||||
};
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
|
||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
|
||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
@@ -355,9 +354,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
|
||||
"Timed out starting the plugin runtime".to_string(),
|
||||
)));
|
||||
.unwrap_or_else(|_| {
|
||||
Err(yaak_plugins::error::Error::PluginErr(
|
||||
"Timed out starting the plugin runtime".to_string(),
|
||||
))
|
||||
});
|
||||
|
||||
let manager = match result {
|
||||
Ok(manager) => manager,
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||
//! need to track which is which.
|
||||
|
||||
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
@@ -21,9 +21,9 @@ use crate::notifications::YaakNotifier;
|
||||
use crate::updates::YaakUpdater;
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
@@ -32,8 +32,8 @@ use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
|
||||
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult,
|
||||
};
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{
|
||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
@@ -42,26 +42,26 @@ use yaak_models::models::{
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
GetWorkspaceActionsResponse,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
|
||||
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc::RpcRouter;
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_sync::sync::SyncOp;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_ws::WebsocketManager;
|
||||
|
||||
/// Per-call context: the window a command was invoked from.
|
||||
@@ -231,17 +231,15 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
async fn call_folder_action(&self, req: CallFolderActionRequest) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
let results =
|
||||
self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
@@ -393,11 +391,17 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
|
||||
Ok(crate::cmd_metadata(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
|
||||
async fn cmd_render_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdRenderTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -405,55 +409,114 @@ async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackRe
|
||||
Ok(crate::cmd_send_feedback(ctx.window.app_handle().clone(), req.feature, req.text).await?)
|
||||
}
|
||||
|
||||
async fn cmd_dismiss_notification<R: Runtime>(ctx: ClientCtx<R>, req: CmdDismissNotificationReq) -> Result<()> {
|
||||
Ok(crate::cmd_dismiss_notification(ctx.window.clone(), &req.notification_id, ctx.window.app_handle().state::<Mutex<YaakNotifier>>()).await?)
|
||||
async fn cmd_dismiss_notification<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDismissNotificationReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_dismiss_notification(
|
||||
ctx.window.clone(),
|
||||
&req.notification_id,
|
||||
ctx.window.app_handle().state::<Mutex<YaakNotifier>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_reflect<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcReflectReq) -> Result<Vec<ServiceDefinition>> {
|
||||
Ok(crate::cmd_grpc_reflect(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
|
||||
async fn cmd_grpc_reflect<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGrpcReflectReq,
|
||||
) -> Result<Vec<ServiceDefinition>> {
|
||||
Ok(crate::cmd_grpc_reflect(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.proto_files,
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_go<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcGoReq) -> Result<String> {
|
||||
Ok(crate::cmd_grpc_go(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
|
||||
Ok(crate::cmd_grpc_go(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.proto_files,
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_restart<R: Runtime>(ctx: ClientCtx<R>, _req: CmdRestartReq) -> Result<()> {
|
||||
Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<EphemeralHttpResponse> {
|
||||
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSendEphemeralRequestReq,
|
||||
) -> Result<EphemeralHttpResponse> {
|
||||
Ok(crate::cmd_send_ephemeral_request(
|
||||
req.request,
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().clone(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_json<R: Runtime>(ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(yaak_commands::data::cmd_format_json(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
|
||||
async fn cmd_format_graphql<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdFormatGraphqlReq,
|
||||
) -> Result<String> {
|
||||
Ok(crate::cmd_format_graphql(&req.text).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
|
||||
async fn cmd_http_response_body<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpResponseBodyReq,
|
||||
) -> Result<FilterResponse> {
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
||||
async fn cmd_http_response_body_path<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
|
||||
async fn cmd_http_request_body<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpRequestBodyReq,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
|
||||
async fn cmd_get_sse_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetSseEventsReq,
|
||||
) -> Result<Vec<ServerSentEvent>> {
|
||||
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
|
||||
async fn cmd_get_http_response_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpResponseEventsReq,
|
||||
) -> Result<Vec<HttpResponseEvent>> {
|
||||
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdImportDataReq,
|
||||
) -> Result<ImportPlan> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
|
||||
}
|
||||
|
||||
@@ -461,16 +524,25 @@ async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) ->
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
|
||||
}
|
||||
|
||||
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
|
||||
async fn cmd_commit_import<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCommitImportReq,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
|
||||
}
|
||||
|
||||
async fn cmd_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
|
||||
async fn cmd_list_import_sources<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdListImportSourcesReq,
|
||||
) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
|
||||
}
|
||||
|
||||
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
|
||||
async fn cmd_import_sources_for_origin<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdImportSourcesForOriginReq,
|
||||
) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
let origin = match (req.file_path, req.url) {
|
||||
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
|
||||
@@ -483,67 +555,115 @@ async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdIm
|
||||
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
async fn cmd_http_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWebsocketRequestActionsReq,
|
||||
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallWebsocketRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
async fn cmd_workspace_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
|
||||
async fn cmd_call_workspace_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallWorkspaceActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
async fn cmd_folder_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdFolderActionsReq,
|
||||
) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
|
||||
async fn cmd_call_folder_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallFolderActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGrpcRequestActionsReq,
|
||||
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
async fn cmd_template_function_summaries<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
async fn cmd_template_function_config<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_http_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallGrpcRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
|
||||
async fn cmd_curl_to_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCurlToRequestReq,
|
||||
) -> Result<HttpRequest> {
|
||||
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -551,83 +671,159 @@ async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -
|
||||
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
|
||||
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data).await?)
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSaveBase64ToBinaryReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_response<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveResponseReq) -> Result<()> {
|
||||
Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
|
||||
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
|
||||
async fn cmd_send_http_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSendHttpRequestReq,
|
||||
) -> Result<HttpResponse> {
|
||||
Ok(crate::cmd_send_http_request(
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
req.request_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdReloadPluginsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
|
||||
async fn cmd_plugin_info<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginInfoReq,
|
||||
) -> Result<PluginMetadata> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteAllGrpcConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
async fn cmd_delete_send_history<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteSendHistoryReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteAllHttpResponsesReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
|
||||
Ok(crate::cmd_new_child_window(ctx.window.clone(), &req.url, &req.label, &req.title, req.inner_size).await?)
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdNewChildWindowReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_new_child_window(
|
||||
ctx.window.clone(),
|
||||
&req.url,
|
||||
&req.label,
|
||||
&req.title,
|
||||
req.inner_size,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_new_main_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewMainWindowReq) -> Result<()> {
|
||||
async fn cmd_new_main_window<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdNewMainWindowReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_new_main_window(ctx.window.app_handle().clone(), &req.url).await?)
|
||||
}
|
||||
|
||||
async fn cmd_check_for_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdCheckForUpdatesReq) -> Result<bool> {
|
||||
Ok(crate::cmd_check_for_updates(ctx.window.clone(), ctx.window.app_handle().state::<Mutex<YaakUpdater>>()).await?)
|
||||
async fn cmd_check_for_updates<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdCheckForUpdatesReq,
|
||||
) -> Result<bool> {
|
||||
Ok(crate::cmd_check_for_updates(
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<YaakUpdater>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_decrypt_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
async fn cmd_decrypt_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDecryptTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
|
||||
async fn cmd_secure_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSecureTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
async fn cmd_get_themes<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetThemesReq,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
async fn cmd_enable_encryption<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdEnableEncryptionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
|
||||
async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdRevealWorkspaceKeyReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
|
||||
async fn cmd_set_workspace_key<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSetWorkspaceKeyReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
|
||||
async fn cmd_disable_encryption<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDisableEncryptionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_default_headers<R: Runtime>(ctx: ClientCtx<R>, req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
|
||||
async fn cmd_default_headers<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDefaultHeadersReq,
|
||||
) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -649,27 +845,45 @@ async fn models_delete<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDeleteReq) -> R
|
||||
Ok(deleted?)
|
||||
}
|
||||
|
||||
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
|
||||
async fn models_duplicate<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsDuplicateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
|
||||
async fn models_websocket_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
|
||||
async fn models_grpc_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGrpcEventsReq,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
async fn models_get_settings<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGetSettingsReq,
|
||||
) -> Result<Settings> {
|
||||
Ok(yaak_commands::models::models_get_settings(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_get_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
|
||||
async fn models_get_graphql_introspection<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGetGraphqlIntrospectionReq,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
|
||||
async fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -680,7 +894,10 @@ async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req:
|
||||
/// freezes the app"). Escape sequences sidestep it. This is a quirk of the
|
||||
/// webview transport, not of the data, so it lives in the adapter rather than
|
||||
/// the shared handler.
|
||||
async fn models_workspace_models<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWorkspaceModelsReq) -> Result<String> {
|
||||
async fn models_workspace_models<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsWorkspaceModelsReq,
|
||||
) -> Result<String> {
|
||||
let json = yaak_commands::models::models_workspace_models(ctx, req).await?;
|
||||
Ok(escape_str_for_webview(&json))
|
||||
}
|
||||
@@ -706,7 +923,10 @@ fn escape_str_for_webview(input: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
|
||||
async fn cmd_git_checkout<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitCheckoutReq,
|
||||
) -> Result<String> {
|
||||
Ok(crate::git_ext::cmd_git_checkout(&req.dir, &req.branch, req.force).await?)
|
||||
}
|
||||
|
||||
@@ -714,31 +934,52 @@ async fn cmd_git_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchReq) ->
|
||||
Ok(crate::git_ext::cmd_git_branch(&req.dir, &req.branch, req.base.as_deref()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_delete_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteBranchReq) -> Result<BranchDeleteResult> {
|
||||
async fn cmd_git_delete_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitDeleteBranchReq,
|
||||
) -> Result<BranchDeleteResult> {
|
||||
Ok(crate::git_ext::cmd_git_delete_branch(&req.dir, &req.branch, req.force).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_delete_remote_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteRemoteBranchReq) -> Result<()> {
|
||||
async fn cmd_git_delete_remote_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitDeleteRemoteBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_delete_remote_branch(&req.dir, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_merge_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitMergeBranchReq) -> Result<()> {
|
||||
async fn cmd_git_merge_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitMergeBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_merge_branch(&req.dir, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_rename_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRenameBranchReq) -> Result<()> {
|
||||
async fn cmd_git_rename_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRenameBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_rename_branch(&req.dir, &req.old_name, &req.new_name).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitStatusReq) -> Result<GitStatusSummary> {
|
||||
async fn cmd_git_status<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitStatusReq,
|
||||
) -> Result<GitStatusSummary> {
|
||||
Ok(crate::git_ext::cmd_git_status(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_branch_info<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchInfoReq) -> Result<GitBranchInfo> {
|
||||
async fn cmd_git_branch_info<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitBranchInfoReq,
|
||||
) -> Result<GitBranchInfo> {
|
||||
Ok(crate::git_ext::cmd_git_branch_info(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_worktree_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitWorktreeStatusReq) -> Result<GitWorktreeStatus> {
|
||||
async fn cmd_git_worktree_status<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitWorktreeStatusReq,
|
||||
) -> Result<GitWorktreeStatus> {
|
||||
Ok(crate::git_ext::cmd_git_worktree_status(&req.dir).await?)
|
||||
}
|
||||
|
||||
@@ -746,15 +987,25 @@ async fn cmd_git_log<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogReq) -> Resul
|
||||
Ok(crate::git_ext::cmd_git_log(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_log_for_file<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogForFileReq) -> Result<Vec<GitCommit>> {
|
||||
async fn cmd_git_log_for_file<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitLogForFileReq,
|
||||
) -> Result<Vec<GitCommit>> {
|
||||
Ok(crate::git_ext::cmd_git_log_for_file(&req.dir, req.rela_path).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_file_diff_for_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitFileDiffForCommitReq) -> Result<GitFileDiff> {
|
||||
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
|
||||
async fn cmd_git_file_diff_for_commit<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitFileDiffForCommitReq,
|
||||
) -> Result<GitFileDiff> {
|
||||
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_initialize<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitInitializeReq) -> Result<()> {
|
||||
async fn cmd_git_initialize<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitInitializeReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_initialize(&req.dir).await?)
|
||||
}
|
||||
|
||||
@@ -778,11 +1029,17 @@ async fn cmd_git_pull<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullReq) -> Res
|
||||
Ok(crate::git_ext::cmd_git_pull(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_pull_force_reset<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullForceResetReq) -> Result<PullResult> {
|
||||
async fn cmd_git_pull_force_reset<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitPullForceResetReq,
|
||||
) -> Result<PullResult> {
|
||||
Ok(crate::git_ext::cmd_git_pull_force_reset(&req.dir, &req.remote, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_pull_merge<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullMergeReq) -> Result<PullResult> {
|
||||
async fn cmd_git_pull_merge<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitPullMergeReq,
|
||||
) -> Result<PullResult> {
|
||||
Ok(crate::git_ext::cmd_git_pull_merge(&req.dir, &req.remote, &req.branch).await?)
|
||||
}
|
||||
|
||||
@@ -794,27 +1051,47 @@ async fn cmd_git_unstage<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitUnstageReq)
|
||||
Ok(crate::git_ext::cmd_git_unstage(&req.dir, req.rela_paths).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_reset_changes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitResetChangesReq) -> Result<()> {
|
||||
async fn cmd_git_reset_changes<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitResetChangesReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_reset_changes(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_restore_files<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFilesReq) -> Result<()> {
|
||||
async fn cmd_git_restore_files<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRestoreFilesReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_files(&req.dir, req.rela_paths).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_restore_file_from_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFileFromCommitReq) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
|
||||
async fn cmd_git_restore_file_from_commit<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRestoreFileFromCommitReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_add_credential<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddCredentialReq) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password).await?)
|
||||
async fn cmd_git_add_credential<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitAddCredentialReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_remotes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRemotesReq) -> Result<Vec<GitRemote>> {
|
||||
async fn cmd_git_remotes<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRemotesReq,
|
||||
) -> Result<Vec<GitRemote>> {
|
||||
Ok(crate::git_ext::cmd_git_remotes(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_add_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddRemoteReq) -> Result<GitRemote> {
|
||||
async fn cmd_git_add_remote<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitAddRemoteReq,
|
||||
) -> Result<GitRemote> {
|
||||
Ok(crate::git_ext::cmd_git_add_remote(&req.dir, &req.name, &req.url).await?)
|
||||
}
|
||||
|
||||
@@ -822,58 +1099,130 @@ async fn cmd_git_rm_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRmRemoteRe
|
||||
Ok(crate::git_ext::cmd_git_rm_remote(&req.dir, &req.name).await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_calculate<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncCalculateReq) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate(ctx.window.app_handle().clone(), &req.workspace_id, &req.sync_dir).await?)
|
||||
async fn cmd_sync_calculate<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSyncCalculateReq,
|
||||
) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate(
|
||||
ctx.window.app_handle().clone(),
|
||||
&req.workspace_id,
|
||||
&req.sync_dir,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_calculate_fs<R: Runtime>(_ctx: ClientCtx<R>, req: CmdSyncCalculateFsReq) -> Result<Vec<SyncOp>> {
|
||||
async fn cmd_sync_calculate_fs<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdSyncCalculateFsReq,
|
||||
) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate_fs(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_apply<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncApplyReq) -> Result<()> {
|
||||
Ok(crate::sync_ext::cmd_sync_apply(ctx.window.app_handle().clone(), req.sync_ops, &req.sync_dir, &req.workspace_id).await?)
|
||||
Ok(crate::sync_ext::cmd_sync_apply(
|
||||
ctx.window.app_handle().clone(),
|
||||
req.sync_ops,
|
||||
&req.sync_dir,
|
||||
&req.workspace_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_delete_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsDeleteConnectionsReq) -> Result<()> {
|
||||
async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsDeleteConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_send(&req.connection_id, req.environment_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_send<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsSendReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_send(
|
||||
&req.connection_id,
|
||||
req.environment_id.as_deref(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_close<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsCloseReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_close(&req.connection_id, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_close<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsCloseReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_close(
|
||||
&req.connection_id,
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_connect<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsConnectReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_connect(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
|
||||
async fn cmd_plugins_search<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsSearchReq,
|
||||
) -> Result<PluginSearchResponse> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_search(ctx.window.app_handle().clone(), &req.query).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_install<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallReq) -> Result<()> {
|
||||
async fn cmd_plugins_install<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsInstallReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install(ctx.window.clone(), &req.name, req.version).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_install_from_directory<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallFromDirectoryReq) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory).await?)
|
||||
async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsInstallFromDirectoryReq,
|
||||
) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_uninstall<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsUninstallReq) -> Result<Plugin> {
|
||||
async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsUninstallReq,
|
||||
) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginInitErrorsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
|
||||
async fn cmd_plugins_updates<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdPluginsUpdatesReq,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_updates(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
|
||||
async fn cmd_plugins_update_all<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdPluginsUpdateAllReq,
|
||||
) -> Result<Vec<PluginNameVersion>> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use url::Url;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
@@ -26,7 +27,6 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
@@ -453,7 +453,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
|
||||
/// Convert WS URL to HTTP URL for cookie filtering
|
||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||
fn convert_ws_url_to_http(ws_url: &Url) -> Url {
|
||||
|
||||
@@ -20,7 +20,9 @@ impl UpdateSource {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum ModelChangeEvent {
|
||||
Upsert { created: bool },
|
||||
Upsert {
|
||||
created: bool,
|
||||
},
|
||||
/// A delete for a workspace implies deletion of every model in that
|
||||
/// workspace — children are bulk-deleted without their own change rows or
|
||||
/// events, and consumers must prune the subtree themselves (the frontend
|
||||
|
||||
@@ -331,17 +331,6 @@ export type ImportSource = {
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type ImportSourceResource = {
|
||||
model: "import_source_resource";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
importSourceId: string;
|
||||
sourceKey: string;
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
snapshot: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
+82
-23
@@ -1,7 +1,21 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
import type {
|
||||
Environment,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
export type BatchUpsertResult = {
|
||||
workspaces: Array<Workspace>;
|
||||
environments: Array<Environment>;
|
||||
folders: Array<Folder>;
|
||||
httpRequests: Array<HttpRequest>;
|
||||
grpcRequests: Array<GrpcRequest>;
|
||||
websocketRequests: Array<WebsocketRequest>;
|
||||
};
|
||||
|
||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
|
||||
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
|
||||
export type ImportDestination =
|
||||
| { type: "new_workspace" }
|
||||
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
|
||||
|
||||
/**
|
||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string, label: string, };
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string },
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
|
||||
export type ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
|
||||
export type ImportPlanAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "unchanged"
|
||||
| "keep_local"
|
||||
| "conflict"
|
||||
| "ignored";
|
||||
|
||||
export type ImportPlanItem = {
|
||||
action: ImportPlanAction;
|
||||
model: ImportResourceType;
|
||||
modelId: string;
|
||||
name: string;
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
*/
|
||||
parentId?: string;
|
||||
selected: boolean;
|
||||
resolution?: ImportConflictResolution;
|
||||
reason?: ImportPlanReason;
|
||||
/**
|
||||
* Fields where the source and the local copy disagree, so the preview can say why
|
||||
*/
|
||||
changedFields: Array<string>;
|
||||
};
|
||||
|
||||
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
|
||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
||||
|
||||
/**
|
||||
* Whether a plan's note is something to know or something to think twice about.
|
||||
*/
|
||||
export type ImportPlanWarningLevel = "info" | "warning";
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
*/
|
||||
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
@@ -29,11 +29,10 @@ use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesRe
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
|
||||
GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
|
||||
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
|
||||
@@ -13,8 +13,9 @@ pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
|
||||
/// `dispatch` call frame, so it cannot borrow, and contexts are cheap clones
|
||||
/// (handles and `Arc`s). Synchronous handlers wrap into this via `rpc_handler!`
|
||||
/// with no visible change.
|
||||
type HandlerFn<Ctx> =
|
||||
Box<dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync>;
|
||||
type HandlerFn<Ctx> = Box<
|
||||
dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync,
|
||||
>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RpcError {
|
||||
@@ -249,9 +250,8 @@ macro_rules! rpc_handler_async {
|
||||
Box::new(|ctx, payload| {
|
||||
Box::pin(async move {
|
||||
let req = serde_json::from_value(payload).map_err($crate::RpcError::from)?;
|
||||
let res = $f(ctx, req)
|
||||
.await
|
||||
.map_err(|e| $crate::RpcError { message: e.to_string() })?;
|
||||
let res =
|
||||
$f(ctx, req).await.map_err(|e| $crate::RpcError { message: e.to_string() })?;
|
||||
serde_json::to_value(res).map_err($crate::RpcError::from)
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MethodDefinition = { name: string, schema: string, clientStreaming: boolean, serverStreaming: boolean, };
|
||||
|
||||
export type ServiceDefinition = { name: string, methods: Array<MethodDefinition>, };
|
||||
+8
-2
@@ -356,8 +356,14 @@ export type ImportSourceResource = {
|
||||
importSourceId: string;
|
||||
sourceKey: string;
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
snapshot: string;
|
||||
/**
|
||||
* `None` once the user has decided not to import this key
|
||||
*/
|
||||
modelId?: string;
|
||||
/**
|
||||
* Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
*/
|
||||
contentHash?: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
Generated
+82
-23
@@ -1,7 +1,21 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
import type {
|
||||
Environment,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
export type BatchUpsertResult = {
|
||||
workspaces: Array<Workspace>;
|
||||
environments: Array<Environment>;
|
||||
folders: Array<Folder>;
|
||||
httpRequests: Array<HttpRequest>;
|
||||
grpcRequests: Array<GrpcRequest>;
|
||||
websocketRequests: Array<WebsocketRequest>;
|
||||
};
|
||||
|
||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
|
||||
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
|
||||
export type ImportDestination =
|
||||
| { type: "new_workspace" }
|
||||
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
|
||||
|
||||
/**
|
||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string, label: string, };
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string },
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
|
||||
export type ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
|
||||
export type ImportPlanAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "unchanged"
|
||||
| "keep_local"
|
||||
| "conflict"
|
||||
| "ignored";
|
||||
|
||||
export type ImportPlanItem = {
|
||||
action: ImportPlanAction;
|
||||
model: ImportResourceType;
|
||||
modelId: string;
|
||||
name: string;
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
*/
|
||||
parentId?: string;
|
||||
selected: boolean;
|
||||
resolution?: ImportConflictResolution;
|
||||
reason?: ImportPlanReason;
|
||||
/**
|
||||
* Fields where the source and the local copy disagree, so the preview can say why
|
||||
*/
|
||||
changedFields: Array<string>;
|
||||
};
|
||||
|
||||
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
|
||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
||||
|
||||
/**
|
||||
* Whether a plan's note is something to know or something to think twice about.
|
||||
*/
|
||||
export type ImportPlanWarningLevel = "info" | "warning";
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
*/
|
||||
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
|
||||
-- so a resource the user chose not to import can be remembered.
|
||||
CREATE TABLE import_source_resources_new
|
||||
(
|
||||
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
import_source_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT,
|
||||
content_hash TEXT,
|
||||
PRIMARY KEY (import_source_id, source_key)
|
||||
);
|
||||
|
||||
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
|
||||
source_key, model_type, model_id, content_hash)
|
||||
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
|
||||
FROM import_source_resources;
|
||||
|
||||
DROP TABLE import_source_resources;
|
||||
|
||||
ALTER TABLE import_source_resources_new
|
||||
RENAME TO import_source_resources;
|
||||
@@ -10,8 +10,8 @@ use yaak_database::SqlitePool;
|
||||
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
pub mod cookies;
|
||||
mod connection_or_tx;
|
||||
pub mod cookies;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod models;
|
||||
|
||||
@@ -3118,8 +3118,12 @@ pub struct ImportSourceResource {
|
||||
pub import_source_id: String,
|
||||
pub source_key: String,
|
||||
pub model_type: String,
|
||||
pub model_id: String,
|
||||
pub snapshot: String,
|
||||
/// `None` once the user has decided not to import this key
|
||||
#[ts(optional)]
|
||||
pub model_id: Option<String>,
|
||||
/// Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
#[ts(optional)]
|
||||
pub content_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
||||
@@ -3134,7 +3138,7 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
||||
source_key: r.get("source_key")?,
|
||||
model_type: r.get("model_type")?,
|
||||
model_id: r.get("model_id")?,
|
||||
snapshot: r.get("snapshot")?,
|
||||
content_hash: r.get("content_hash")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,7 @@ impl<'a> ClientDb<'a> {
|
||||
let Some(response_id) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some()
|
||||
{
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
}
|
||||
if fs::remove_file(&path).is_ok() {
|
||||
|
||||
@@ -34,7 +34,7 @@ impl<'a> ClientDb<'a> {
|
||||
ImportSourceResourceIden::SourceKey,
|
||||
ImportSourceResourceIden::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.values_panic([
|
||||
CurrentTimestamp.into(),
|
||||
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
|
||||
resource.import_source_id.as_str().into(),
|
||||
resource.source_key.as_str().into(),
|
||||
resource.model_type.as_str().into(),
|
||||
resource.model_id.as_str().into(),
|
||||
resource.snapshot.as_str().into(),
|
||||
resource.model_id.clone().into(),
|
||||
resource.content_hash.clone().into(),
|
||||
])
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
|
||||
ImportSourceResourceIden::UpdatedAt,
|
||||
ImportSourceResourceIden::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
|
||||
@@ -24,8 +24,8 @@ mod websocket_events;
|
||||
mod websocket_requests;
|
||||
mod workspace_metas;
|
||||
pub mod workspaces;
|
||||
pub use model_changes::PersistedModelChange;
|
||||
pub(crate) use duplicate_name::conflict_free_name;
|
||||
pub use model_changes::PersistedModelChange;
|
||||
|
||||
const MAX_HISTORY_ITEMS: usize = 20;
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ use crate::models::{
|
||||
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
|
||||
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
|
||||
WebsocketConnection,
|
||||
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
|
||||
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
|
||||
WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden,
|
||||
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta,
|
||||
WorkspaceMetaIden,
|
||||
};
|
||||
use crate::util::UpdateSource;
|
||||
use log::warn;
|
||||
|
||||
@@ -109,6 +109,28 @@ pub enum ImportDestination {
|
||||
pub struct ImportPlanWarning {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
#[serde(default)]
|
||||
pub level: ImportPlanWarningLevel,
|
||||
}
|
||||
|
||||
/// Whether a plan's note is something to know or something to think twice about.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportPlanWarningLevel {
|
||||
#[default]
|
||||
Info,
|
||||
Warning,
|
||||
}
|
||||
|
||||
impl ImportPlanWarning {
|
||||
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Info }
|
||||
}
|
||||
|
||||
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Warning }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an import's contents came from, used to link the committed workspace back to it.
|
||||
@@ -169,6 +191,16 @@ pub enum ImportPlanAction {
|
||||
Unchanged,
|
||||
KeepLocal,
|
||||
Conflict,
|
||||
/// Present in the source but previously turned down; selecting it imports it again
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportPlanReason {
|
||||
MovedIntoIgnoredFolder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
@@ -193,6 +225,11 @@ pub struct ImportPlanItem {
|
||||
pub selected: bool,
|
||||
#[ts(optional)]
|
||||
pub resolution: Option<ImportConflictResolution>,
|
||||
#[ts(optional)]
|
||||
pub reason: Option<ImportPlanReason>,
|
||||
/// Fields where the source and the local copy disagree, so the preview can say why
|
||||
#[serde(default)]
|
||||
pub changed_fields: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
|
||||
+13
@@ -11,6 +11,7 @@ export type AnyModel =
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::error::Result;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use crate::models::SyncModel;
|
||||
use chrono::Utc;
|
||||
use log::{info, warn};
|
||||
@@ -11,6 +10,7 @@ use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{SyncState, WorkspaceMeta};
|
||||
use yaak_models::util::{UpdateSource, get_workspace_export_resources};
|
||||
|
||||
@@ -9,6 +9,7 @@ async-trait = "0.1"
|
||||
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
sha2 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
+1065
-215
File diff suppressed because it is too large.
Load diff
@@ -668,9 +668,10 @@ mod tests {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let store = FakeBodyStore { body: b"hello".to_vec(), reads: RefCell::new(Vec::new()) };
|
||||
|
||||
let info_payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_test".to_string() },
|
||||
);
|
||||
let info_payload =
|
||||
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
|
||||
response_id: "rs_test".to_string(),
|
||||
});
|
||||
let info = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&store,
|
||||
@@ -716,9 +717,10 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unreadable_response_body_becomes_an_error_reply() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_never_persisted".to_string() },
|
||||
);
|
||||
let payload =
|
||||
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
|
||||
response_id: "rs_never_persisted".to_string(),
|
||||
});
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
@@ -727,7 +729,11 @@ mod tests {
|
||||
|
||||
match result {
|
||||
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
|
||||
assert!(resp.error.contains("rs_never_persisted"), "unhelpful error: {}", resp.error)
|
||||
assert!(
|
||||
resp.error.contains("rs_never_persisted"),
|
||||
"unhelpful error: {}",
|
||||
resp.error
|
||||
)
|
||||
}
|
||||
other => panic!("unexpected missing-response result: {other:?}"),
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<li>Git-friendly plain-text project storage</li>
|
||||
<li>Environment variables and template functions</li>
|
||||
<li>Request chaining and dynamic values</li>
|
||||
<li>OAuth 2.0, Bearer, Basic, Digest, API Key, AWS, JWT, and NTLM authentication</li>
|
||||
<li>OAuth 2.0, Bearer, Basic, API Key, AWS, JWT, and NTLM authentication</li>
|
||||
<li>Import from cURL, Postman, Insomnia, and OpenAPI</li>
|
||||
<li>Extensible plugin system</li>
|
||||
</ul>
|
||||
|
||||
Generated
+909
-487
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -25,7 +25,6 @@
|
||||
"plugins/auth-aws",
|
||||
"plugins/auth-basic",
|
||||
"plugins/auth-bearer",
|
||||
"plugins/auth-digest",
|
||||
"plugins/auth-jwt",
|
||||
"plugins/auth-ntlm",
|
||||
"plugins/auth-oauth2",
|
||||
@@ -136,7 +135,9 @@
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@vitest/mocker": "^4.1.11",
|
||||
"js-yaml": "^4.3.1",
|
||||
"underscore": "^1.13.8",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9"
|
||||
},
|
||||
"packageManager": "npm@11.11.1"
|
||||
|
||||
@@ -76,12 +76,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
cmd_send_http_request: (payload, db) => {
|
||||
const requestId = str(payload, "requestId");
|
||||
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
|
||||
return sendHttpRequest(
|
||||
db,
|
||||
requestId,
|
||||
str(payload, "environmentId"),
|
||||
str(payload, "cookieJarId"),
|
||||
);
|
||||
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
|
||||
},
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
@@ -239,7 +234,6 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
|
||||
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
||||
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
||||
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
||||
{ name: "digest", label: "Digest Auth", shortLabel: "Digest" },
|
||||
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
||||
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
||||
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
|
||||
@@ -268,16 +262,10 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: [
|
||||
"WebSocket requests aren't available in the browser yet",
|
||||
"websocket",
|
||||
],
|
||||
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
|
||||
// Anything that needs files the page can't reach.
|
||||
cmd_import_data: [
|
||||
"Importing from a file needs a filesystem, which a browser tab has no",
|
||||
"localFiles",
|
||||
],
|
||||
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
|
||||
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
|
||||
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
||||
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
||||
@@ -310,14 +298,8 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_tokens_to_string: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test": "vp test --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^10.1.0"
|
||||
"@faker-js/faker": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
|
||||
@@ -142,6 +142,7 @@ exports[`template-function-faker > exports all expected template functions 1`] =
|
||||
"faker.location.longitude",
|
||||
"faker.location.nearbyGPSCoordinate",
|
||||
"faker.location.ordinalDirection",
|
||||
"faker.location.postalAddress",
|
||||
"faker.location.secondaryAddress",
|
||||
"faker.location.state",
|
||||
"faker.location.street",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^2.0.10",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"hono": "^4.12.34",
|
||||
"hono": "^4.13.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# Digest Authentication
|
||||
|
||||
An HTTP Digest Authentication plugin that implements
|
||||
[RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616), with fallback to the
|
||||
older [RFC 2617](https://datatracker.ietf.org/doc/html/rfc2617) and
|
||||
[RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) behaviour that many
|
||||
servers still speak.
|
||||
|
||||
## Overview
|
||||
|
||||
Digest Authentication proves you know a password without ever putting it on the
|
||||
wire. The server issues a one-time `nonce`, and the client answers with a hash
|
||||
over the credentials, the nonce and the request itself.
|
||||
|
||||
## How it works
|
||||
|
||||
Because the digest is computed over a server-issued nonce, the challenge has to
|
||||
be fetched before the real request can be signed. On each send, the plugin:
|
||||
|
||||
1. Sends an unauthenticated probe of the same method and URL, carrying no
|
||||
headers of its own so it cannot authorize anything
|
||||
2. Reads the `WWW-Authenticate: Digest …` challenge from the `401` response
|
||||
3. Computes the response hash and returns the `Authorization` header
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Username**: Username or user identifier
|
||||
- **Password**: Password or authentication token
|
||||
- **Realm** (advanced): Only needed when the server offers more than one realm.
|
||||
Leave it empty and the first challenge the server prefers is used.
|
||||
|
||||
## Supported challenges
|
||||
|
||||
| Feature | Supported |
|
||||
| ---------- | ------------------------------------------------------------- |
|
||||
| Algorithm | `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess` |
|
||||
| `qop` | `auth`, `auth-int`, and challenges that omit `qop` (RFC 2069) |
|
||||
| `opaque` | Echoed back when the server sends one |
|
||||
| `userhash` | Declined with `userhash=false` |
|
||||
|
||||
`auth-int` is used when the server offers it and Yaak has the request body in
|
||||
hand. Bodies that are streamed from disk or above the size Yaak passes to auth
|
||||
plugins aren't available to hash, so those requests use `auth` instead.
|
||||
|
||||
Credentials are normalized to Unicode NFC before hashing, per RFC 7616 §4.
|
||||
Usernames outside ASCII are sent as an RFC 5987 extended value (`username*`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Server did not offer Digest authentication**: The endpoint answered the probe
|
||||
with a different scheme, or with no `WWW-Authenticate` header at all. Check the
|
||||
URL, and whether the endpoint requires auth in the first place.
|
||||
- **Unsupported Digest algorithm**: The server asked for an algorithm this plugin
|
||||
doesn't implement, such as `SHA-512-256`.
|
||||
- **401 Unauthorized**: Verify the username and password. If the server offers
|
||||
several realms, set the Realm field to the one your account belongs to.
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@yaak/auth-digest",
|
||||
"displayName": "Digest Authentication",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Authenticate requests using HTTP Digest authentication",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mountain-loop/yaak.git",
|
||||
"directory": "plugins/auth-digest"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev",
|
||||
"test": "vp test --run tests"
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** A single challenge from a `WWW-Authenticate` header. */
|
||||
export interface AuthChallenge {
|
||||
scheme: string;
|
||||
params: Record<string, string>;
|
||||
/** The `token68` form (`NTLM TlRMTVNT…`), which carries no parameters. */
|
||||
token68?: string;
|
||||
}
|
||||
|
||||
export interface DigestChallenge {
|
||||
realm: string;
|
||||
nonce: string;
|
||||
opaque?: string;
|
||||
qop?: string[];
|
||||
/** Echoed back verbatim, so it must keep the server's own spelling. */
|
||||
algorithm?: string;
|
||||
stale: boolean;
|
||||
userhash: boolean;
|
||||
}
|
||||
|
||||
export interface DigestAuthorizationOptions {
|
||||
username: string;
|
||||
password: string;
|
||||
method: string;
|
||||
uri: string;
|
||||
body: string | null;
|
||||
challenge: DigestChallenge;
|
||||
cnonce: string;
|
||||
nc: number;
|
||||
}
|
||||
|
||||
const TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+";
|
||||
const PARAM_RE = new RegExp(`^(${TOKEN})\\s*=\\s*([\\s\\S]*)$`);
|
||||
const SCHEME_RE = new RegExp(`^(${TOKEN})(?:\\s+([\\s\\S]*))?$`);
|
||||
const TOKEN68_RE = /^[A-Za-z0-9\-._~+/]+=*$/;
|
||||
|
||||
const SUPPORTED_ALGORITHMS = ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"];
|
||||
const SUPPORTED_QOPS = ["auth", "auth-int"];
|
||||
|
||||
/**
|
||||
* Split a header value on commas that aren't inside a quoted string. Both
|
||||
* challenges and their parameters are comma-separated, so this yields a flat
|
||||
* list that {@link parseChallenges} re-groups.
|
||||
*/
|
||||
function splitOnCommas(value: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i]!;
|
||||
if (quoted && char === "\\" && i + 1 < value.length) {
|
||||
current += char + value[++i]!;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
current += char;
|
||||
} else if (char === "," && !quoted) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
parts.push(current);
|
||||
|
||||
return parts.map((p) => p.trim()).filter((p) => p !== "");
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return trimmed.slice(1, -1).replace(/\\([\s\S])/g, "$1");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseChallenges(headerValues: string[]): AuthChallenge[] {
|
||||
const challenges: AuthChallenge[] = [];
|
||||
|
||||
for (const headerValue of headerValues) {
|
||||
let current: AuthChallenge | null = null;
|
||||
|
||||
for (const part of splitOnCommas(headerValue)) {
|
||||
const param = PARAM_RE.exec(part);
|
||||
if (param != null && current != null) {
|
||||
current.params[param[1]!.toLowerCase()] = unquote(param[2]!);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheme = SCHEME_RE.exec(part);
|
||||
if (scheme == null) continue;
|
||||
|
||||
current = { scheme: scheme[1]!, params: {} };
|
||||
challenges.push(current);
|
||||
|
||||
const rest = scheme[2]?.trim();
|
||||
if (rest == null || rest === "") continue;
|
||||
|
||||
if (TOKEN68_RE.test(rest)) {
|
||||
current.token68 = rest;
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstParam = PARAM_RE.exec(rest);
|
||||
if (firstParam != null) {
|
||||
current.params[firstParam[1]!.toLowerCase()] = unquote(firstParam[2]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return challenges;
|
||||
}
|
||||
|
||||
export function toDigestChallenge(params: Record<string, string>): DigestChallenge {
|
||||
const qop = params.qop
|
||||
?.split(",")
|
||||
.map((v) => v.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
realm: params.realm ?? "",
|
||||
nonce: params.nonce ?? "",
|
||||
opaque: params.opaque,
|
||||
qop: qop == null || qop.length === 0 ? undefined : qop,
|
||||
algorithm: params.algorithm,
|
||||
stale: params.stale?.toLowerCase() === "true",
|
||||
userhash: params.userhash?.toLowerCase() === "true",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `MD5`, `MD5-sess`, `SHA-256` and `SHA-256-sess`, tolerating the `SHA256`
|
||||
* spelling some servers use. Returns null for anything else.
|
||||
*/
|
||||
function resolveAlgorithm(algorithm: string | undefined): { hash: string; sess: boolean } | null {
|
||||
const value = (algorithm ?? "MD5").trim().toLowerCase();
|
||||
const sess = value.endsWith("-sess");
|
||||
const base = (sess ? value.slice(0, -"-sess".length) : value).replace(/-/g, "");
|
||||
if (base === "md5") return { hash: "md5", sess };
|
||||
if (base === "sha256") return { hash: "sha256", sess };
|
||||
return null;
|
||||
}
|
||||
|
||||
function unsupportedAlgorithmError(algorithm: string | undefined): Error {
|
||||
return new Error(
|
||||
`Unsupported Digest algorithm: ${algorithm ?? "MD5"}. ` +
|
||||
`Supported algorithms are ${SUPPORTED_ALGORITHMS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function unsupportedQopError(qop: string[]): Error {
|
||||
return new Error(
|
||||
`Unsupported Digest qop: ${qop.join(", ")}. Supported values are ${SUPPORTED_QOPS.join(" and ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that would stop this challenge from being answered, or null if it
|
||||
* can be. Selection asks the whole question at once so a challenge that fails
|
||||
* on any count is passed over for the next one the server offered, rather than
|
||||
* chosen and then failed on later.
|
||||
*/
|
||||
function challengeProblem(challenge: DigestChallenge): Error | null {
|
||||
if (resolveAlgorithm(challenge.algorithm) == null) {
|
||||
return unsupportedAlgorithmError(challenge.algorithm);
|
||||
}
|
||||
if (challenge.nonce === "") {
|
||||
return new Error('Digest challenge is missing the required "nonce" parameter');
|
||||
}
|
||||
if (challenge.qop != null && !challenge.qop.some((q) => SUPPORTED_QOPS.includes(q))) {
|
||||
return unsupportedQopError(challenge.qop);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the challenge to answer. Servers list challenges strongest-first
|
||||
* (RFC 7616 §3.7), so the first one we can compute is the one to use.
|
||||
*/
|
||||
export function selectDigestChallenge(
|
||||
challenges: AuthChallenge[],
|
||||
realm?: string,
|
||||
): DigestChallenge {
|
||||
const digestChallenges = challenges.filter((c) => c.scheme.toLowerCase() === "digest");
|
||||
|
||||
if (digestChallenges.length === 0) {
|
||||
const offered = challenges.map((c) => c.scheme).join(", ");
|
||||
throw new Error(
|
||||
offered === ""
|
||||
? "Server did not offer Digest authentication (no WWW-Authenticate header in the response)"
|
||||
: `Server did not offer Digest authentication. It offered: ${offered}`,
|
||||
);
|
||||
}
|
||||
|
||||
const inRealm =
|
||||
realm == null || realm === ""
|
||||
? digestChallenges
|
||||
: digestChallenges.filter((c) => c.params.realm === realm);
|
||||
|
||||
if (inRealm.length === 0) {
|
||||
const offered = digestChallenges.map((c) => JSON.stringify(c.params.realm ?? "")).join(", ");
|
||||
throw new Error(`Server did not offer a Digest realm named "${realm}". It offered: ${offered}`);
|
||||
}
|
||||
|
||||
const candidates = inRealm.map((c) => toDigestChallenge(c.params));
|
||||
const answerable = candidates.find((c) => challengeProblem(c) == null);
|
||||
if (answerable == null) throw challengeProblem(candidates[0]!);
|
||||
|
||||
return answerable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer `auth-int` only when the body is in hand, since its digest covers the
|
||||
* exact bytes sent. A body offered as `null` is either an empty one or one Yaak
|
||||
* didn't hand over (too large, or streamed from a file), and the two are
|
||||
* indistinguishable from here. When `auth-int` is all the server offers it is
|
||||
* still used, hashing the empty body: that is exactly right for the empty case
|
||||
* and no worse than refusing outright for the other.
|
||||
*/
|
||||
function selectQop(qop: string[], body: string | null): "auth" | "auth-int" {
|
||||
if (qop.includes("auth-int") && (body != null || !qop.includes("auth"))) return "auth-int";
|
||||
if (qop.includes("auth")) return "auth";
|
||||
throw unsupportedQopError(qop);
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
/** RFC 5987 `ext-value`, used for usernames that a quoted-string can't carry. */
|
||||
function encodeExtended(value: string): string {
|
||||
const encoded = encodeURIComponent(value).replace(
|
||||
/['()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
return `UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
export function buildDigestAuthorization(options: DigestAuthorizationOptions): string {
|
||||
const { method, uri, body, challenge, cnonce, nc } = options;
|
||||
|
||||
// RFC 7616 §4 hashes credentials in Normalization Form C, so a name typed as
|
||||
// a combining sequence digests the same as its precomposed spelling.
|
||||
const username = options.username.normalize("NFC");
|
||||
const password = options.password.normalize("NFC");
|
||||
|
||||
const algorithm = resolveAlgorithm(challenge.algorithm);
|
||||
if (algorithm == null) throw unsupportedAlgorithmError(challenge.algorithm);
|
||||
|
||||
const hash = (value: string) => createHash(algorithm.hash).update(value, "utf8").digest("hex");
|
||||
const qop = challenge.qop == null ? null : selectQop(challenge.qop, body);
|
||||
const ncHex = nc.toString(16).padStart(8, "0");
|
||||
|
||||
const secret = hash(`${username}:${challenge.realm}:${password}`);
|
||||
const ha1 = algorithm.sess ? hash(`${secret}:${challenge.nonce}:${cnonce}`) : secret;
|
||||
const ha2 =
|
||||
qop === "auth-int" ? hash(`${method}:${uri}:${hash(body ?? "")}`) : hash(`${method}:${uri}`);
|
||||
|
||||
// Without qop the server speaks RFC 2069, where the client contributes nothing
|
||||
// to the digest and so must not send cnonce, nc or qop back.
|
||||
const response =
|
||||
qop == null
|
||||
? hash(`${ha1}:${challenge.nonce}:${ha2}`)
|
||||
: hash(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`);
|
||||
|
||||
const params: string[] = [];
|
||||
params.push(
|
||||
/^[\x20-\x7E]*$/.test(username)
|
||||
? `username=${quote(username)}`
|
||||
: `username*=${encodeExtended(username)}`,
|
||||
);
|
||||
params.push(`realm=${quote(challenge.realm)}`);
|
||||
params.push(`uri=${quote(uri)}`);
|
||||
if (challenge.algorithm != null) params.push(`algorithm=${challenge.algorithm}`);
|
||||
params.push(`nonce=${quote(challenge.nonce)}`);
|
||||
if (qop != null) {
|
||||
params.push(`nc=${ncHex}`);
|
||||
params.push(`cnonce=${quote(cnonce)}`);
|
||||
params.push(`qop=${qop}`);
|
||||
}
|
||||
params.push(`response=${quote(response)}`);
|
||||
if (challenge.opaque != null) params.push(`opaque=${quote(challenge.opaque)}`);
|
||||
if (challenge.userhash) params.push("userhash=false");
|
||||
|
||||
return `Digest ${params.join(", ")}`;
|
||||
}
|
||||
|
||||
/** The origin-form request-target the digest is computed over. */
|
||||
export function requestTarget(url: string): string {
|
||||
const absolute = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(url) ? url : `http://${url}`;
|
||||
const parsed = new URL(absolute);
|
||||
return `${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PluginDefinition } from "@yaakapp/api";
|
||||
|
||||
import {
|
||||
buildDigestAuthorization,
|
||||
parseChallenges,
|
||||
requestTarget,
|
||||
selectDigestChallenge,
|
||||
} from "./digest";
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
authentication: {
|
||||
name: "digest",
|
||||
label: "Digest Auth",
|
||||
shortLabel: "Digest",
|
||||
args: [
|
||||
{
|
||||
type: "text",
|
||||
name: "username",
|
||||
label: "Username",
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "password",
|
||||
label: "Password",
|
||||
optional: true,
|
||||
password: true,
|
||||
},
|
||||
{
|
||||
type: "accordion",
|
||||
label: "Advanced",
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
name: "realm",
|
||||
label: "Realm",
|
||||
optional: true,
|
||||
description: "Only needed when the server offers more than one realm",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
async onApply(ctx, { values, method, url, body }) {
|
||||
const username = values.username ? String(values.username) : "";
|
||||
const password = values.password ? String(values.password) : "";
|
||||
const realm = values.realm ? String(values.realm) : undefined;
|
||||
|
||||
// Digest needs a server-issued nonce, so the challenge has to be provoked
|
||||
// before the real request can be signed. The probe carries nothing but the
|
||||
// method and URL: a cookie or an API key header would let it authorize the
|
||||
// very operation it is only meant to ask permission for, and there is no
|
||||
// telling a routing header from a credential by looking at it.
|
||||
const { httpResponse } = await ctx.httpRequest.send({ httpRequest: { method, url } });
|
||||
|
||||
const headerValues = httpResponse.headers
|
||||
.filter((h) => h.name.toLowerCase() === "www-authenticate")
|
||||
.map((h) => h.value);
|
||||
|
||||
const challenge = selectDigestChallenge(parseChallenges(headerValues), realm);
|
||||
const value = buildDigestAuthorization({
|
||||
username,
|
||||
password,
|
||||
method,
|
||||
uri: requestTarget(url),
|
||||
body,
|
||||
challenge,
|
||||
cnonce: randomBytes(16).toString("hex"),
|
||||
nc: 1,
|
||||
});
|
||||
|
||||
return { setHeaders: [{ name: "Authorization", value }] };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,390 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
buildDigestAuthorization,
|
||||
parseChallenges,
|
||||
requestTarget,
|
||||
selectDigestChallenge,
|
||||
toDigestChallenge,
|
||||
} from "../src/digest";
|
||||
|
||||
function paramOf(header: string, name: string): string | undefined {
|
||||
return parseChallenges([header])[0]?.params[name];
|
||||
}
|
||||
|
||||
describe("parseChallenges", () => {
|
||||
test("parses quoted and unquoted parameters", () => {
|
||||
expect(parseChallenges(['Digest realm="test", algorithm=MD5, stale=TRUE'])).toEqual([
|
||||
{ scheme: "Digest", params: { realm: "test", algorithm: "MD5", stale: "TRUE" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps commas and escapes inside quoted values", () => {
|
||||
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "qop")).toEqual(
|
||||
"auth,auth-int",
|
||||
);
|
||||
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "realm")).toEqual(
|
||||
'a "quoted" realm',
|
||||
);
|
||||
});
|
||||
|
||||
test("tolerates whitespace around the equals sign", () => {
|
||||
expect(paramOf('Digest realm = "test"', "realm")).toEqual("test");
|
||||
});
|
||||
|
||||
test("splits multiple challenges in a single header", () => {
|
||||
expect(parseChallenges(['Basic realm="a", Digest realm="b", nonce="n"'])).toEqual([
|
||||
{ scheme: "Basic", params: { realm: "a" } },
|
||||
{ scheme: "Digest", params: { realm: "b", nonce: "n" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("collects challenges across repeated headers", () => {
|
||||
expect(parseChallenges(['Digest realm="a"', "Negotiate"]).map((c) => c.scheme)).toEqual([
|
||||
"Digest",
|
||||
"Negotiate",
|
||||
]);
|
||||
});
|
||||
|
||||
test("captures token68 credentials rather than reading them as parameters", () => {
|
||||
expect(parseChallenges(["NTLM TlRMTVNTUAACAAAAAA=="])).toEqual([
|
||||
{ scheme: "NTLM", params: {}, token68: "TlRMTVNTUAACAAAAAA==" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("lower-cases parameter names", () => {
|
||||
expect(paramOf('Digest Realm="test", NONCE="n"', "realm")).toEqual("test");
|
||||
expect(paramOf('Digest Realm="test", NONCE="n"', "nonce")).toEqual("n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDigestChallenge", () => {
|
||||
test("splits qop and reads the boolean flags", () => {
|
||||
const challenge = toDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=" auth , AUTH-INT ", stale=true'])[0]!
|
||||
.params,
|
||||
);
|
||||
expect(challenge.qop).toEqual(["auth", "auth-int"]);
|
||||
expect(challenge.stale).toBe(true);
|
||||
expect(challenge.userhash).toBe(false);
|
||||
});
|
||||
|
||||
test("treats a missing qop as absent rather than empty", () => {
|
||||
expect(toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])[0]!.params).qop).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("reads userhash", () => {
|
||||
expect(
|
||||
toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n", userhash=TRUE'])[0]!.params)
|
||||
.userhash,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectDigestChallenge", () => {
|
||||
const md5 = 'Digest realm="a", nonce="n1", algorithm=MD5';
|
||||
const sha = 'Digest realm="b", nonce="n2", algorithm=SHA-256';
|
||||
|
||||
test("takes the first Digest challenge the server prefers", () => {
|
||||
expect(selectDigestChallenge(parseChallenges([sha, md5])).nonce).toEqual("n2");
|
||||
});
|
||||
|
||||
test("skips challenges whose algorithm is not supported", () => {
|
||||
const unsupported = 'Digest realm="c", nonce="n0", algorithm=SHA-512-256';
|
||||
expect(selectDigestChallenge(parseChallenges([unsupported, md5])).nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("skips challenges whose qop cannot be answered", () => {
|
||||
const unanswerable = 'Digest realm="c", nonce="n0", qop="auth-conf"';
|
||||
expect(selectDigestChallenge(parseChallenges([unanswerable, md5])).nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("skips challenges that carry no nonce", () => {
|
||||
expect(selectDigestChallenge(parseChallenges(['Digest realm="c"', md5])).nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("reports the first challenge's problem when none can be answered", () => {
|
||||
expect(() =>
|
||||
selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="c", nonce="n", qop="auth-conf"', 'Digest realm="d"']),
|
||||
),
|
||||
).toThrow("Unsupported Digest qop: auth-conf");
|
||||
});
|
||||
|
||||
test("matches the case-insensitive scheme name", () => {
|
||||
expect(selectDigestChallenge(parseChallenges(['digest realm="a", nonce="n1"'])).nonce).toEqual(
|
||||
"n1",
|
||||
);
|
||||
});
|
||||
|
||||
test("selects by realm when one is given", () => {
|
||||
expect(selectDigestChallenge(parseChallenges([sha, md5]), "a").nonce).toEqual("n1");
|
||||
});
|
||||
|
||||
test("errors when the requested realm is not offered", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges([sha, md5]), "nope")).toThrow(
|
||||
'Server did not offer a Digest realm named "nope". It offered: "b", "a"',
|
||||
);
|
||||
});
|
||||
|
||||
test("errors when the server offers no Digest challenge", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges(['Basic realm="a"', "Negotiate"]))).toThrow(
|
||||
"Server did not offer Digest authentication. It offered: Basic, Negotiate",
|
||||
);
|
||||
});
|
||||
|
||||
test("errors when the response carries no challenge at all", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges([]))).toThrow(
|
||||
"no WWW-Authenticate header in the response",
|
||||
);
|
||||
});
|
||||
|
||||
test("errors when no offered algorithm is supported", () => {
|
||||
expect(() =>
|
||||
selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="c", nonce="n", algorithm=SHA-512-256']),
|
||||
),
|
||||
).toThrow("Unsupported Digest algorithm: SHA-512-256");
|
||||
});
|
||||
|
||||
test("errors when the challenge has no nonce", () => {
|
||||
expect(() => selectDigestChallenge(parseChallenges(['Digest realm="c"']))).toThrow(
|
||||
'Digest challenge is missing the required "nonce" parameter',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1
|
||||
describe("RFC 7616 §3.9.1 worked example", () => {
|
||||
const headers = [
|
||||
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=SHA-256, ' +
|
||||
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=MD5, ' +
|
||||
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
];
|
||||
const common = {
|
||||
username: "Mufasa",
|
||||
password: "Circle of Life",
|
||||
method: "GET",
|
||||
uri: "/dir/index.html",
|
||||
body: null,
|
||||
cnonce: "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
|
||||
nc: 1,
|
||||
};
|
||||
|
||||
test("SHA-256", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...common,
|
||||
challenge: selectDigestChallenge(parseChallenges(headers)),
|
||||
}),
|
||||
).toEqual(
|
||||
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
|
||||
'algorithm=SHA-256, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
|
||||
'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
);
|
||||
});
|
||||
|
||||
test("MD5", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...common,
|
||||
challenge: selectDigestChallenge(parseChallenges([headers[1]!])),
|
||||
}),
|
||||
).toEqual(
|
||||
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
|
||||
'algorithm=MD5, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
|
||||
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
|
||||
'response="8ca523f5e9506fed4657c9700eebdbec", ' +
|
||||
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2617#section-3.5
|
||||
describe("RFC 2617 §3.5 worked example", () => {
|
||||
test("MD5 with qop=auth", () => {
|
||||
const challenge = selectDigestChallenge(
|
||||
parseChallenges([
|
||||
'Digest realm="testrealm@host.com", qop="auth,auth-int", ' +
|
||||
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
username: "Mufasa",
|
||||
password: "Circle Of Life",
|
||||
method: "GET",
|
||||
uri: "/dir/index.html",
|
||||
body: null,
|
||||
challenge,
|
||||
cnonce: "0a4f113b",
|
||||
nc: 1,
|
||||
}),
|
||||
).toContain('response="6629fae49393a05397450978507c4ef1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDigestAuthorization", () => {
|
||||
const base = {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
method: "POST",
|
||||
uri: "/api",
|
||||
body: null as string | null,
|
||||
cnonce: "abc123",
|
||||
nc: 1,
|
||||
};
|
||||
|
||||
test("omits the client's contribution when the server offers no qop", () => {
|
||||
const header = buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
});
|
||||
expect(header).not.toContain("qop=");
|
||||
expect(header).not.toContain("cnonce=");
|
||||
expect(header).not.toContain("nc=");
|
||||
// MD5(HA1:nonce:HA2), per RFC 2069.
|
||||
expect(header).toContain('response="24644771b8983deed818b83aeb3ac381"');
|
||||
});
|
||||
|
||||
test("omits algorithm when the challenge did not name one", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
}),
|
||||
).not.toContain("algorithm=");
|
||||
});
|
||||
|
||||
test("uses auth-int over the body when the server offers it", () => {
|
||||
const header = buildDigestAuthorization({
|
||||
...base,
|
||||
body: '{"a":1}',
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
|
||||
),
|
||||
});
|
||||
expect(header).toContain("qop=auth-int");
|
||||
});
|
||||
|
||||
test("falls back to auth when no body was handed over", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
|
||||
),
|
||||
}),
|
||||
).toContain("qop=auth");
|
||||
});
|
||||
|
||||
test("uses auth-int over an empty body when it is the only qop offered", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth-int"']),
|
||||
),
|
||||
}),
|
||||
).toContain("qop=auth-int");
|
||||
});
|
||||
|
||||
test("rejects a qop it cannot compute", () => {
|
||||
expect(() =>
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop="auth-conf"']),
|
||||
),
|
||||
}),
|
||||
).toThrow("Unsupported Digest qop: auth-conf");
|
||||
});
|
||||
|
||||
test("mixes the cnonce into HA1 for -sess algorithms", () => {
|
||||
const sess = buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5-sess']),
|
||||
),
|
||||
});
|
||||
const plain = buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5']),
|
||||
),
|
||||
});
|
||||
expect(sess).toContain("algorithm=MD5-sess");
|
||||
expect(sess).not.toEqual(plain);
|
||||
});
|
||||
|
||||
test("declines userhash when the server advertises it", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
challenge: selectDigestChallenge(
|
||||
parseChallenges(['Digest realm="r", nonce="n", qop=auth, userhash=true']),
|
||||
),
|
||||
}),
|
||||
).toContain("userhash=false");
|
||||
});
|
||||
|
||||
test("escapes quotes in the credentials it echoes back", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
username: 'a"b',
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
}),
|
||||
).toContain('username="a\\"b"');
|
||||
});
|
||||
|
||||
test("normalizes credentials to NFC before hashing", () => {
|
||||
const challenge = selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"']));
|
||||
// "Jäsøn" spelled with a combining diaeresis rather than a precomposed "ä".
|
||||
const decomposed = buildDigestAuthorization({
|
||||
...base,
|
||||
username: "Ja\u0308s\u00f8n",
|
||||
password: "pa\u0308ss",
|
||||
challenge,
|
||||
});
|
||||
const precomposed = buildDigestAuthorization({
|
||||
...base,
|
||||
username: "J\u00e4s\u00f8n",
|
||||
password: "p\u00e4ss",
|
||||
challenge,
|
||||
});
|
||||
expect(decomposed).toEqual(precomposed);
|
||||
});
|
||||
|
||||
test("sends a non-ASCII username as an RFC 5987 extended value", () => {
|
||||
expect(
|
||||
buildDigestAuthorization({
|
||||
...base,
|
||||
username: "Jäsøn Doe",
|
||||
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
|
||||
}),
|
||||
).toContain("username*=UTF-8''J%C3%A4s%C3%B8n%20Doe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestTarget", () => {
|
||||
test("keeps the path and query", () => {
|
||||
expect(requestTarget("https://example.org/dir/index.html?a=b&c=d")).toEqual(
|
||||
"/dir/index.html?a=b&c=d",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses a bare slash when there is no path", () => {
|
||||
expect(requestTarget("https://example.org")).toEqual("/");
|
||||
});
|
||||
|
||||
test("handles a URL with no scheme", () => {
|
||||
expect(requestTarget("localhost:8080/thing")).toEqual("/thing");
|
||||
});
|
||||
});
|
||||
@@ -1,375 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import { plugin } from "../src";
|
||||
|
||||
function apply(ctx: Context, values: Record<string, string>, over: Partial<ApplyArgs> = {}) {
|
||||
return plugin.authentication!.onApply(ctx, {
|
||||
values,
|
||||
headers: [],
|
||||
url: "https://example.org/dir/index.html?a=b",
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
type ApplyArgs = Parameters<NonNullable<typeof plugin.authentication>["onApply"]>[1];
|
||||
|
||||
function ctxRespondingWith(headers: Array<{ name: string; value: string }>): {
|
||||
ctx: Context;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const send = vi.fn().mockResolvedValue({ httpResponse: { headers } });
|
||||
return { ctx: { httpRequest: { send } } as unknown as Context, send };
|
||||
}
|
||||
|
||||
describe("auth-digest onApply", () => {
|
||||
test("probes with the same method and URL, without credentials or body", async () => {
|
||||
const { ctx, send } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
await apply(ctx, { username: "user", password: "pass" }, { method: "POST", body: "hello" });
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
httpRequest: { method: "POST", url: "https://example.org/dir/index.html?a=b" },
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps credential-bearing headers off the probe", async () => {
|
||||
const { ctx, send } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
await apply(
|
||||
ctx,
|
||||
{ username: "user", password: "pass" },
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: [
|
||||
{ name: "Cookie", value: "session=abc" },
|
||||
{ name: "X-Api-Key", value: "secret" },
|
||||
{ name: "Authorization", value: "Bearer stale" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
httpRequest: { method: "DELETE", url: "https://example.org/dir/index.html?a=b" },
|
||||
});
|
||||
});
|
||||
|
||||
test("signs the request-target rather than the whole URL", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
const result = await apply(ctx, { username: "user", password: "pass" });
|
||||
|
||||
expect(result.setHeaders?.[0]?.name).toEqual("Authorization");
|
||||
expect(result.setHeaders?.[0]?.value).toContain('uri="/dir/index.html?a=b"');
|
||||
});
|
||||
|
||||
test("uses a fresh cnonce on every apply", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
|
||||
]);
|
||||
|
||||
const first = await apply(ctx, { username: "user", password: "pass" });
|
||||
const second = await apply(ctx, { username: "user", password: "pass" });
|
||||
|
||||
expect(first.setHeaders?.[0]?.value).not.toEqual(second.setHeaders?.[0]?.value);
|
||||
});
|
||||
|
||||
test("treats missing credentials as empty strings", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "www-authenticate", value: 'Digest realm="r", nonce="n"' },
|
||||
]);
|
||||
|
||||
expect((await apply(ctx, {})).setHeaders?.[0]?.value).toContain('username=""');
|
||||
});
|
||||
|
||||
test("fails clearly when the server does not offer Digest", async () => {
|
||||
const { ctx } = ctxRespondingWith([{ name: "WWW-Authenticate", value: 'Basic realm="r"' }]);
|
||||
|
||||
await expect(apply(ctx, { username: "user", password: "pass" })).rejects.toThrow(
|
||||
"Server did not offer Digest authentication. It offered: Basic",
|
||||
);
|
||||
});
|
||||
|
||||
test("selects the realm the user configured", async () => {
|
||||
const { ctx } = ctxRespondingWith([
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="one", nonce="n1"' },
|
||||
{ name: "WWW-Authenticate", value: 'Digest realm="two", nonce="n2"' },
|
||||
]);
|
||||
|
||||
expect(
|
||||
(await apply(ctx, { username: "user", password: "pass", realm: "two" })).setHeaders?.[0]
|
||||
?.value,
|
||||
).toContain('nonce="n2"');
|
||||
});
|
||||
});
|
||||
|
||||
function nextComma(value: string, from: number): number {
|
||||
const index = value.indexOf(",", from);
|
||||
return index < 0 ? value.length : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `Digest name=value, name="value"` credentials by scanning, rather than
|
||||
* with a global regex: a repeated character class in front of the `=` backtracks
|
||||
* quadratically over a long run of the characters it accepts.
|
||||
*/
|
||||
function parseCredentials(header: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
let i = header.indexOf(" ") + 1;
|
||||
|
||||
while (i < header.length) {
|
||||
const equals = header.indexOf("=", i);
|
||||
if (equals < 0) break;
|
||||
|
||||
const name = header.slice(i, equals).trim().toLowerCase();
|
||||
i = equals + 1;
|
||||
|
||||
let value = "";
|
||||
if (header[i] === '"') {
|
||||
for (i++; i < header.length && header[i] !== '"'; i++) {
|
||||
if (header[i] === "\\") i++;
|
||||
value += header[i];
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
const end = nextComma(header, i);
|
||||
value = header.slice(i, end).trim();
|
||||
i = end;
|
||||
}
|
||||
|
||||
params[name] = value;
|
||||
i = nextComma(header, i) + 1;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimally correct Digest server, hashing inline rather than through the
|
||||
* plugin's own helpers so the round trip can't agree with itself on a mistake.
|
||||
*/
|
||||
function startDigestServer(config: {
|
||||
username: string;
|
||||
password: string;
|
||||
realm: string;
|
||||
nonce: string;
|
||||
algorithm?: string;
|
||||
qop?: string;
|
||||
/** Offer a second, unrelated realm ahead of the real one. */
|
||||
decoyRealm?: string;
|
||||
}): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const hashName = (config.algorithm ?? "MD5").toLowerCase().startsWith("sha-256")
|
||||
? "sha256"
|
||||
: "md5";
|
||||
const sess = (config.algorithm ?? "").toLowerCase().endsWith("-sess");
|
||||
const hash = (value: string) => createHash(hashName).update(value, "utf8").digest("hex");
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
const authorization = req.headers.authorization;
|
||||
if (authorization == null || !authorization.startsWith("Digest ")) {
|
||||
const challenge = [
|
||||
`Digest realm="${config.realm}"`,
|
||||
`nonce="${config.nonce}"`,
|
||||
`algorithm=${config.algorithm ?? "MD5"}`,
|
||||
config.qop == null ? null : `qop="${config.qop}"`,
|
||||
'opaque="0p4qu3"',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
res.setHeader(
|
||||
"WWW-Authenticate",
|
||||
config.decoyRealm == null
|
||||
? [challenge]
|
||||
: [
|
||||
`Digest realm="${config.decoyRealm}", nonce="wrong-nonce", algorithm=MD5`,
|
||||
challenge,
|
||||
],
|
||||
);
|
||||
res.writeHead(401).end("unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const params = parseCredentials(authorization);
|
||||
|
||||
const secret = hash(`${config.username}:${config.realm}:${config.password}`);
|
||||
const ha1 = sess ? hash(`${secret}:${params.nonce}:${params.cnonce}`) : secret;
|
||||
const ha2 =
|
||||
params.qop === "auth-int"
|
||||
? hash(`${req.method}:${params.uri}:${hash(Buffer.concat(chunks).toString("utf8"))}`)
|
||||
: hash(`${req.method}:${params.uri}`);
|
||||
const expected =
|
||||
params.qop == null
|
||||
? hash(`${ha1}:${params.nonce}:${ha2}`)
|
||||
: hash(`${ha1}:${params.nonce}:${params.nc}:${params.cnonce}:${params.qop}:${ha2}`);
|
||||
|
||||
const ok =
|
||||
params.username === config.username &&
|
||||
params.realm === config.realm &&
|
||||
params.nonce === config.nonce &&
|
||||
params.uri === req.url &&
|
||||
params.opaque === "0p4qu3" &&
|
||||
params.response === expected;
|
||||
|
||||
res.writeHead(ok ? 200 : 401).end(ok ? "welcome" : "denied");
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise<void>((done) => server.close(() => done())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends for real, so the plugin sees the headers a live server actually returns. */
|
||||
function realContext(): Context {
|
||||
return {
|
||||
httpRequest: {
|
||||
async send({ httpRequest }: { httpRequest: { method?: string; url?: string } }) {
|
||||
const res = await fetch(httpRequest.url!, { method: httpRequest.method });
|
||||
await res.text();
|
||||
return {
|
||||
httpResponse: {
|
||||
headers: [...res.headers].map(([name, value]) => ({ name, value })),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Context;
|
||||
}
|
||||
|
||||
describe("auth-digest against a live server", () => {
|
||||
let close: (() => Promise<void>) | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await close?.();
|
||||
close = null;
|
||||
});
|
||||
|
||||
for (const algorithm of ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"]) {
|
||||
test(`authenticates with algorithm=${algorithm}`, async () => {
|
||||
const server = await startDigestServer({
|
||||
username: "Mufasa",
|
||||
password: "Circle of Life",
|
||||
realm: "http-auth@example.org",
|
||||
nonce: "7ypf/xlj9XXwfDPEoM4URrv",
|
||||
algorithm,
|
||||
qop: "auth",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/dir/index.html?a=b`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "Mufasa", password: "Circle of Life" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: result.setHeaders![0]!.value },
|
||||
});
|
||||
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("authenticates a POST body with qop=auth-int", async () => {
|
||||
const body = '{"hello":"world"}';
|
||||
const server = await startDigestServer({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
realm: "api@example.org",
|
||||
nonce: "n0nc3",
|
||||
algorithm: "SHA-256",
|
||||
qop: "auth,auth-int",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/submit`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "user", password: "pass" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "POST",
|
||||
body,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
expect(result.setHeaders![0]!.value).toContain("qop=auth-int");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { Authorization: result.setHeaders![0]!.value },
|
||||
});
|
||||
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
|
||||
});
|
||||
|
||||
test("authenticates against an RFC 2069 server that offers no qop", async () => {
|
||||
const server = await startDigestServer({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
realm: "legacy@example.org",
|
||||
nonce: "old-nonce",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/legacy`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "user", password: "pass" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
|
||||
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
|
||||
});
|
||||
|
||||
test("picks the configured realm out of several the server offers", async () => {
|
||||
const server = await startDigestServer({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
realm: "second@example.org",
|
||||
nonce: "n0nc3",
|
||||
qop: "auth",
|
||||
decoyRealm: "first@example.org",
|
||||
});
|
||||
close = server.close;
|
||||
|
||||
const url = `${server.url}/multi`;
|
||||
const result = await plugin.authentication!.onApply(realContext(), {
|
||||
values: { username: "user", password: "pass", realm: "second@example.org" },
|
||||
headers: [],
|
||||
url,
|
||||
method: "GET",
|
||||
body: null,
|
||||
contextId: "ctx",
|
||||
});
|
||||
|
||||
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"dev": "yaakcli dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": "yaakcli dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user