@marimo-team/islands 0.23.16-dev47 → 0.23.16-dev49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{common-CrqeqHZV.js → common-Wat9e-dh.js} +1 -1
- package/dist/main.js +2 -2
- package/dist/{reveal-component-DpS6VbRW.js → reveal-component-DpO-LAhf.js} +1 -1
- package/package.json +1 -1
- package/src/components/editor/chrome/panels/write-secret-modal.tsx +40 -12
- package/src/components/editor/connections/__tests__/json-credentials.test.ts +114 -0
- package/src/components/editor/connections/__tests__/paths.test.ts +39 -0
- package/src/components/editor/connections/components.tsx +6 -2
- package/src/components/editor/connections/database/__tests__/__snapshots__/as-code.test.ts.snap +21 -5
- package/src/components/editor/connections/database/__tests__/as-code.test.ts +17 -0
- package/src/components/editor/connections/database/as-code.ts +16 -2
- package/src/components/editor/connections/database/schemas.ts +6 -5
- package/src/components/editor/connections/form-renderers.tsx +99 -1
- package/src/components/editor/connections/json-credentials.ts +57 -0
- package/src/components/editor/connections/paths.ts +21 -0
- package/src/components/editor/connections/secret-combobox.tsx +41 -20
- package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +38 -2
- package/src/components/editor/connections/storage/__tests__/as-code.test.ts +41 -0
- package/src/components/editor/connections/storage/as-code.ts +39 -15
- package/src/components/editor/connections/storage/schemas.ts +2 -2
- package/src/components/forms/options.ts +2 -1
|
@@ -36338,7 +36338,7 @@ ${d}`,
|
|
|
36338
36338
|
return Logger.warn("Failed to get version from mount config"), null;
|
|
36339
36339
|
}
|
|
36340
36340
|
}
|
|
36341
|
-
marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.16-
|
|
36341
|
+
marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.16-dev49");
|
|
36342
36342
|
showCodeInRunModeAtom = atom(true);
|
|
36343
36343
|
atom(null);
|
|
36344
36344
|
var import_compiler_runtime = require_compiler_runtime();
|
package/dist/main.js
CHANGED
|
@@ -26,7 +26,7 @@ import { $ as notebookOutline, $t as publishedCellClasses, A as ChevronDownIcon,
|
|
|
26
26
|
import { __tla as __tla_1 } from "./chunk-5FQGJX7Z-B9WoXK1R.js";
|
|
27
27
|
import { o as useSize, s as Root$1, u as createLucideIcon } from "./dist-DIlxLFXG.js";
|
|
28
28
|
import { A as SquareFunction, C as DEFAULT_COLOR_SCHEME, D as SCALE_TYPE_DESCRIPTIONS, E as EMPTY_VALUE$1, O as TIME_UNIT_DESCRIPTIONS, S as DEFAULT_AGGREGATION, T as DEFAULT_TIME_UNIT, _ as AGGREGATION_TYPE_DESCRIPTIONS, a as AGGREGATION_FNS$1, b as COLOR_SCHEMES, c as COLOR_BY_FIELDS, d as NONE_VALUE, f as SELECTABLE_DATA_TYPES, g as TIME_UNITS, h as STRING_AGGREGATION_FNS, i as convertDataTypeToSelectable, j as ChartColumn, k as escapeFieldName, l as COMBINED_TIME_UNITS, m as SORT_TYPES, n as createSpecWithoutData, o as BIN_AGGREGATION, p as SINGLE_TIME_UNITS, r as isFieldSet, s as CHART_TYPES, t as augmentSpecWithData, u as ChartType, v as AGGREGATION_TYPE_ICON, w as DEFAULT_MAX_BINS_FACET, x as COUNT_FIELD, y as CHART_TYPE_ICON } from "./spec-CQKL_D3i.js";
|
|
29
|
-
import { $ as CommandSeparator, $t as Kbd, A as ErrorState, At as getPageIndexForRow, B as ContextMenuTrigger, Bt as dateToLocalISODate, C as downloadHTMLAsImage, Ct as inferFieldTypes, D as ColumnName, Dt as DelayMount, E as downloadSizeLimitAtom, Et as ColumnChartSpecModel, F as getUserColumnVisibilityCounts, Ft as TOO_MANY_ROWS, G as DateRangePicker, Gt as TabsList, H as useSelectList, Ht as dateToLocalISOTime, I as ContextMenu, It as toFieldTypes, J as Command, Jt as ChartInfoState, K as Combobox, Kt as TabsTrigger, L as ContextMenuContent, Lt as getMimeValues, M as prettifyRowColumnCount, Mt as loadTableData, N as prettifyRowCount, Nt as INDEX_COLUMN_NAME, O as ColumnPreviewContainer, Ot as useIntersectionObserver, P as getColumnCountForDisplay, Pt as SELECT_COLUMN_ID, Q as CommandList, Qt as RenderTextWithLinks, R as ContextMenuItem, Rt as isNullishFilter, S as downloadExportedFile, St as generateColumns, T as Filenames, Tt as ColumnChartContext, U as CompactChipRow, Ut as Tabs, V as useInternalStateWithSync, Vt as dateToLocalISODateTime, W as DatePicker, Wt as TabsContent, X as CommandInput, Xt as LazyVegaEmbed, Y as CommandEmpty, Yt as ChartLoadingState, Z as CommandItem, Zt as useOverflowDetection, _ as DataTable, _n as ChevronLeft, _t as TableCell, an as TextWrap, at as contextAwarePanelType, b as downloadBlob, bt as TableRow, c as SLIDE_TYPE_OPTIONS_BY_VALUE, cn as EyeOff, ct as slotsController, d as Switch, dn as Download, en as HtmlOutput, et as smartMatch, f as RadioGroup, fn as Code, g as OutputRenderer, gn as ChevronsDownUp, gt as TableBody, h as OutputArea, hn as ChevronsLeft, ht as Table, i as showCodeInRunModeAtom, in as $fae977aafc393c5c$export$6b862160d295c8e, it as contextAwarePanelOwner, j as LoadingState, jt as loadTableAndRawData, k as EmptyState, kt as usePrevious$1, lt as Toggle, m as JsonOutput, mn as ChevronsRight, mt as Provider$1, n as useNotebookCodeAvailable, nn as safeLocale, nt as PANEL_TYPES, on as GripHorizontal, ot as isCellAwareAtom, p as RadioGroupItem, pn as ChevronsUpDown, pt as Fill, q as ComboboxItem, qt as ChartErrorState, r as marimoVersionAtom, rn as $fae977aafc393c5c$export$588937bcd60ade55, rt as contextAwarePanelOpen, sn as Funnel, st as SlotNames, t as cellDomProps, tn as EmotionCacheProvider, tt as ContextAwarePanelItem, u as Slide, un as Ellipsis, v as InstallPackageButton, vn as ArrowDownWideNarrow, vt as TableHead, w as Progress, wt as renderCellValue, x as downloadByURL, xt as NAMELESS_COLUMN_PREFIX, y as ADD_PRINTING_CLASS, yt as TableHeader, z as ContextMenuSeparator, zt as Maps, __tla as __tla_2 } from "./common-
|
|
29
|
+
import { $ as CommandSeparator, $t as Kbd, A as ErrorState, At as getPageIndexForRow, B as ContextMenuTrigger, Bt as dateToLocalISODate, C as downloadHTMLAsImage, Ct as inferFieldTypes, D as ColumnName, Dt as DelayMount, E as downloadSizeLimitAtom, Et as ColumnChartSpecModel, F as getUserColumnVisibilityCounts, Ft as TOO_MANY_ROWS, G as DateRangePicker, Gt as TabsList, H as useSelectList, Ht as dateToLocalISOTime, I as ContextMenu, It as toFieldTypes, J as Command, Jt as ChartInfoState, K as Combobox, Kt as TabsTrigger, L as ContextMenuContent, Lt as getMimeValues, M as prettifyRowColumnCount, Mt as loadTableData, N as prettifyRowCount, Nt as INDEX_COLUMN_NAME, O as ColumnPreviewContainer, Ot as useIntersectionObserver, P as getColumnCountForDisplay, Pt as SELECT_COLUMN_ID, Q as CommandList, Qt as RenderTextWithLinks, R as ContextMenuItem, Rt as isNullishFilter, S as downloadExportedFile, St as generateColumns, T as Filenames, Tt as ColumnChartContext, U as CompactChipRow, Ut as Tabs, V as useInternalStateWithSync, Vt as dateToLocalISODateTime, W as DatePicker, Wt as TabsContent, X as CommandInput, Xt as LazyVegaEmbed, Y as CommandEmpty, Yt as ChartLoadingState, Z as CommandItem, Zt as useOverflowDetection, _ as DataTable, _n as ChevronLeft, _t as TableCell, an as TextWrap, at as contextAwarePanelType, b as downloadBlob, bt as TableRow, c as SLIDE_TYPE_OPTIONS_BY_VALUE, cn as EyeOff, ct as slotsController, d as Switch, dn as Download, en as HtmlOutput, et as smartMatch, f as RadioGroup, fn as Code, g as OutputRenderer, gn as ChevronsDownUp, gt as TableBody, h as OutputArea, hn as ChevronsLeft, ht as Table, i as showCodeInRunModeAtom, in as $fae977aafc393c5c$export$6b862160d295c8e, it as contextAwarePanelOwner, j as LoadingState, jt as loadTableAndRawData, k as EmptyState, kt as usePrevious$1, lt as Toggle, m as JsonOutput, mn as ChevronsRight, mt as Provider$1, n as useNotebookCodeAvailable, nn as safeLocale, nt as PANEL_TYPES, on as GripHorizontal, ot as isCellAwareAtom, p as RadioGroupItem, pn as ChevronsUpDown, pt as Fill, q as ComboboxItem, qt as ChartErrorState, r as marimoVersionAtom, rn as $fae977aafc393c5c$export$588937bcd60ade55, rt as contextAwarePanelOpen, sn as Funnel, st as SlotNames, t as cellDomProps, tn as EmotionCacheProvider, tt as ContextAwarePanelItem, u as Slide, un as Ellipsis, v as InstallPackageButton, vn as ArrowDownWideNarrow, vt as TableHead, w as Progress, wt as renderCellValue, x as downloadByURL, xt as NAMELESS_COLUMN_PREFIX, y as ADD_PRINTING_CLASS, yt as TableHeader, z as ContextMenuSeparator, zt as Maps, __tla as __tla_2 } from "./common-Wat9e-dh.js";
|
|
30
30
|
import { c as Calendar, i as createReducerAndAtoms, n as useOnUnmount, o as ToggleLeft, t as useOnMount } from "./useLifecycle-CMJGqBaY.js";
|
|
31
31
|
import { t as Check } from "./check-DP7U0H0G.js";
|
|
32
32
|
import { A as Icon, C as logNever, D as $18f2051aff69b9bf$export$a54013f0d02a8f82, E as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7, F as createCollection, I as X, M as clamp$2, N as usePrevious$2, P as useDirection, R as ChevronDown, S as assertNever, a as SelectGroup, c as SelectSeparator, d as NativeSelect, i as SelectContent, j as Trigger$1, l as SelectTrigger, n as capitalize, o as SelectItem, r as Select, s as SelectLabel, t as Strings, u as SelectValue, w as $a916eb452884faea$export$b7a616150fdb9f44 } from "./strings-DIy9NAmg.js";
|
|
@@ -38147,7 +38147,7 @@ ${c}
|
|
|
38147
38147
|
function _temp2$2(e) {
|
|
38148
38148
|
e.target === e.currentTarget && e.key === "Enter" && (e.preventDefault(), e.stopPropagation(), e.currentTarget.click());
|
|
38149
38149
|
}
|
|
38150
|
-
var LazySlidesComponent = import_react.lazy(() => import("./reveal-component-
|
|
38150
|
+
var LazySlidesComponent = import_react.lazy(() => import("./reveal-component-DpO-LAhf.js"));
|
|
38151
38151
|
const SlidesLayoutRenderer = ({ layout: e, setLayout: r, cells: c, mode: l }) => {
|
|
38152
38152
|
var _a5;
|
|
38153
38153
|
let u = useAtomValue(kioskModeAtom), d = l === "read" || u, f = useAtomValue(numColumnsAtom) > 1, [p, m] = (0, import_react.useState)(null), { slideCells: h, skippedIds: g, noOutputIds: _, slideTypes: v, startCellIndex: y } = (0, import_react.useMemo)(() => computeSlideCellsInfo(c, e), [
|
|
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
|
|
|
9
9
|
import { ut as outputIsStale } from "./html-to-image-3w0AakWW.js";
|
|
10
10
|
import "./chunk-5FQGJX7Z-B9WoXK1R.js";
|
|
11
11
|
import { u as createLucideIcon } from "./dist-DIlxLFXG.js";
|
|
12
|
-
import { a as DEFAULT_DECK_TRANSITION, cn as EyeOff, dt as PanelGroup, fn as Code, ft as PanelResizeHandle, l as SlideSidebar, ln as Expand, n as useNotebookCodeAvailable, o as DEFAULT_DECK_VERTICAL_ALIGN, s as DEFAULT_SLIDE_TYPE, t as cellDomProps, u as Slide, ut as Panel } from "./common-
|
|
12
|
+
import { a as DEFAULT_DECK_TRANSITION, cn as EyeOff, dt as PanelGroup, fn as Code, ft as PanelResizeHandle, l as SlideSidebar, ln as Expand, n as useNotebookCodeAvailable, o as DEFAULT_DECK_VERTICAL_ALIGN, s as DEFAULT_SLIDE_TYPE, t as cellDomProps, u as Slide, ut as Panel } from "./common-Wat9e-dh.js";
|
|
13
13
|
import { R as ChevronDown } from "./strings-DIy9NAmg.js";
|
|
14
14
|
import { X as useDebouncedCallback } from "./input-C5K4VBvB.js";
|
|
15
15
|
import { v as kioskModeAtom } from "./toDate-CqhBWjvO.js";
|
package/package.json
CHANGED
|
@@ -19,9 +19,11 @@ import {
|
|
|
19
19
|
SelectTrigger,
|
|
20
20
|
SelectValue,
|
|
21
21
|
} from "@/components/ui/select";
|
|
22
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
22
23
|
import { toast } from "@/components/ui/use-toast";
|
|
23
24
|
import { useRequestClient } from "@/core/network/requests";
|
|
24
25
|
import type { ListSecretKeysResponse } from "@/core/network/types";
|
|
26
|
+
import { flattenSecretValue } from "../../connections/json-credentials";
|
|
25
27
|
|
|
26
28
|
// dotenv providers should be at the top
|
|
27
29
|
export function sortProviders(providers: ListSecretKeysResponse["keys"]) {
|
|
@@ -44,10 +46,21 @@ export const WriteSecretModal: React.FC<{
|
|
|
44
46
|
onClose: () => void;
|
|
45
47
|
onSuccess: (secretName: string) => void;
|
|
46
48
|
initialValue?: string;
|
|
47
|
-
|
|
49
|
+
multiline?: boolean;
|
|
50
|
+
}> = ({
|
|
51
|
+
providerNames,
|
|
52
|
+
onClose,
|
|
53
|
+
onSuccess,
|
|
54
|
+
initialValue = "",
|
|
55
|
+
multiline = false,
|
|
56
|
+
}) => {
|
|
48
57
|
const { writeSecret } = useRequestClient();
|
|
49
58
|
const [key, setKey] = React.useState("");
|
|
50
|
-
|
|
59
|
+
// Multiline values are flattened so they fit on a single dotenv line;
|
|
60
|
+
// the textarea still wraps visually for readability.
|
|
61
|
+
const [value, setValue] = React.useState(() =>
|
|
62
|
+
multiline ? flattenSecretValue(initialValue) : initialValue,
|
|
63
|
+
);
|
|
51
64
|
const [location, setLocation] = React.useState<string | undefined>(
|
|
52
65
|
providerNames[0],
|
|
53
66
|
);
|
|
@@ -65,7 +78,9 @@ export const WriteSecretModal: React.FC<{
|
|
|
65
78
|
return;
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
|
|
81
|
+
const secretValue = multiline ? flattenSecretValue(value) : value;
|
|
82
|
+
|
|
83
|
+
if (!key || !secretValue || !location) {
|
|
69
84
|
toast({
|
|
70
85
|
title: "Error",
|
|
71
86
|
description: "Please fill in all fields.",
|
|
@@ -77,7 +92,7 @@ export const WriteSecretModal: React.FC<{
|
|
|
77
92
|
try {
|
|
78
93
|
await writeSecret({
|
|
79
94
|
key,
|
|
80
|
-
value,
|
|
95
|
+
value: secretValue,
|
|
81
96
|
provider,
|
|
82
97
|
name: location,
|
|
83
98
|
});
|
|
@@ -120,14 +135,27 @@ export const WriteSecretModal: React.FC<{
|
|
|
120
135
|
</div>
|
|
121
136
|
<div className="grid gap-2">
|
|
122
137
|
<Label htmlFor="value">Value</Label>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
{multiline ? (
|
|
139
|
+
<Textarea
|
|
140
|
+
id="value"
|
|
141
|
+
value={value}
|
|
142
|
+
onChange={(e) => setValue(flattenSecretValue(e.target.value))}
|
|
143
|
+
required={true}
|
|
144
|
+
className="my-0 font-code text-xs break-all"
|
|
145
|
+
rows={8}
|
|
146
|
+
autoFocus={true}
|
|
147
|
+
autoComplete="off"
|
|
148
|
+
/>
|
|
149
|
+
) : (
|
|
150
|
+
<Input
|
|
151
|
+
id="value"
|
|
152
|
+
type="password"
|
|
153
|
+
value={value}
|
|
154
|
+
onChange={(e) => setValue(e.target.value)}
|
|
155
|
+
required={true}
|
|
156
|
+
autoComplete="off"
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
131
159
|
{/* http is prone to man-in-the-middle */}
|
|
132
160
|
{isHttpUrl() && (
|
|
133
161
|
<FormDescription>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
escapePythonString,
|
|
5
|
+
flattenSecretValue,
|
|
6
|
+
looksLikeJson,
|
|
7
|
+
resolveJsonCredential,
|
|
8
|
+
} from "../json-credentials";
|
|
9
|
+
import { prefixPath } from "../paths";
|
|
10
|
+
import { prefixSecret } from "../secrets";
|
|
11
|
+
|
|
12
|
+
describe("resolveJsonCredential", () => {
|
|
13
|
+
test("resolves a file path", () => {
|
|
14
|
+
expect(
|
|
15
|
+
resolveJsonCredential(prefixPath("/etc/secrets/key.json"), (v) => v),
|
|
16
|
+
).toEqual({ kind: "path", path: "/etc/secrets/key.json" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("resolves a secret via printSecret", () => {
|
|
20
|
+
expect(
|
|
21
|
+
resolveJsonCredential(prefixSecret("MY_CREDS"), () => "_my_creds"),
|
|
22
|
+
).toEqual({ kind: "json", expr: "json.loads(_my_creds)" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("resolves a raw JSON literal", () => {
|
|
26
|
+
expect(
|
|
27
|
+
resolveJsonCredential('{"type":"service_account"}', (v) => v),
|
|
28
|
+
).toEqual({
|
|
29
|
+
kind: "json",
|
|
30
|
+
expr: 'json.loads(r"""{"type":"service_account"}""")',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("uses a raw string so escaped newlines in a private_key survive", () => {
|
|
35
|
+
const credentialsJson =
|
|
36
|
+
'{"private_key":"-----BEGIN PRIVATE KEY-----\\nabc123\\n-----END PRIVATE KEY-----\\n"}';
|
|
37
|
+
const credential = resolveJsonCredential(credentialsJson, (v) => v);
|
|
38
|
+
expect(credential).toEqual({
|
|
39
|
+
kind: "json",
|
|
40
|
+
expr: `json.loads(r"""${credentialsJson}""")`,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("prefers path over secret-looking values", () => {
|
|
45
|
+
// A path-prefixed value wins even if the path text starts with "env:".
|
|
46
|
+
expect(
|
|
47
|
+
resolveJsonCredential(prefixPath("env:not-a-secret"), (v) => v),
|
|
48
|
+
).toEqual({ kind: "path", path: "env:not-a-secret" });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("looksLikeJson", () => {
|
|
53
|
+
test("detects objects and arrays, including partial paste", () => {
|
|
54
|
+
expect(looksLikeJson('{"type":"service_account"}')).toBe(true);
|
|
55
|
+
expect(looksLikeJson(" {")).toBe(true);
|
|
56
|
+
expect(looksLikeJson("[1, 2]")).toBe(true);
|
|
57
|
+
expect(looksLikeJson("/etc/secrets/key.json")).toBe(false);
|
|
58
|
+
expect(looksLikeJson("MY_SECRET")).toBe(false);
|
|
59
|
+
expect(looksLikeJson("")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("drives the path vs. create-secret combobox actions exclusively", () => {
|
|
63
|
+
// These mirror how SECRET_TEXTAREA_RENDERER wires allowCustomValue /
|
|
64
|
+
// allowCreateSecret, so a path and a JSON paste never both offer (or
|
|
65
|
+
// both hide) the wrong action.
|
|
66
|
+
const allowCustomValue = (search: string) => !looksLikeJson(search);
|
|
67
|
+
const allowCreateSecret = (search: string) =>
|
|
68
|
+
!search || looksLikeJson(search);
|
|
69
|
+
|
|
70
|
+
expect(allowCustomValue("/etc/secrets/key.json")).toBe(true);
|
|
71
|
+
expect(allowCreateSecret("/etc/secrets/key.json")).toBe(false);
|
|
72
|
+
|
|
73
|
+
expect(allowCustomValue('{"type":"service_account"}')).toBe(false);
|
|
74
|
+
expect(allowCreateSecret('{"type":"service_account"}')).toBe(true);
|
|
75
|
+
|
|
76
|
+
expect(allowCreateSecret("")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("flattenSecretValue", () => {
|
|
81
|
+
test("minifies valid JSON", () => {
|
|
82
|
+
expect(
|
|
83
|
+
flattenSecretValue(`{
|
|
84
|
+
"type": "service_account",
|
|
85
|
+
"project_id": "test"
|
|
86
|
+
}`),
|
|
87
|
+
).toBe('{"type":"service_account","project_id":"test"}');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("strips newlines from non-JSON", () => {
|
|
91
|
+
expect(flattenSecretValue("line1\nline2\r\nline3")).toBe("line1line2line3");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns empty for blank input", () => {
|
|
95
|
+
expect(flattenSecretValue("")).toBe("");
|
|
96
|
+
expect(flattenSecretValue(" \n ")).toBe("");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("escapePythonString", () => {
|
|
101
|
+
test("escapes backslashes and quotes", () => {
|
|
102
|
+
expect(escapePythonString('C:\\tmp\\"creds".json')).toBe(
|
|
103
|
+
'C:\\\\tmp\\\\\\"creds\\".json',
|
|
104
|
+
);
|
|
105
|
+
expect(escapePythonString("/etc/secrets/key.json")).toBe(
|
|
106
|
+
"/etc/secrets/key.json",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("escapes control characters", () => {
|
|
111
|
+
expect(escapePythonString("line1\nline2")).toBe("line1\\nline2");
|
|
112
|
+
expect(escapePythonString("a\tb\rc")).toBe("a\\tb\\rc");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
isPath,
|
|
5
|
+
prefixPath,
|
|
6
|
+
type PathPlaceholder,
|
|
7
|
+
unprefixPath,
|
|
8
|
+
} from "../paths";
|
|
9
|
+
|
|
10
|
+
describe("paths", () => {
|
|
11
|
+
test("isPath", () => {
|
|
12
|
+
expect(isPath("path:/etc/secrets/key.json")).toBe(true);
|
|
13
|
+
expect(isPath("path:")).toBe(true);
|
|
14
|
+
expect(isPath("/etc/secrets/key.json")).toBe(false);
|
|
15
|
+
expect(isPath("env:MY_SECRET")).toBe(false);
|
|
16
|
+
expect(isPath("")).toBe(false);
|
|
17
|
+
expect(isPath(null)).toBe(false);
|
|
18
|
+
expect(isPath(undefined)).toBe(false);
|
|
19
|
+
expect(isPath(123)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("prefixPath", () => {
|
|
23
|
+
expect(prefixPath("/etc/secrets/key.json")).toBe(
|
|
24
|
+
"path:/etc/secrets/key.json",
|
|
25
|
+
);
|
|
26
|
+
expect(prefixPath("")).toBe("path:");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("unprefixPath", () => {
|
|
30
|
+
expect(unprefixPath("path:/etc/secrets/key.json" as PathPlaceholder)).toBe(
|
|
31
|
+
"/etc/secrets/key.json",
|
|
32
|
+
);
|
|
33
|
+
expect(unprefixPath("path:" as PathPlaceholder)).toBe("");
|
|
34
|
+
// Only strips the leading prefix — path may itself contain "path:".
|
|
35
|
+
expect(unprefixPath("path:/tmp/path:weird.json" as PathPlaceholder)).toBe(
|
|
36
|
+
"/tmp/path:weird.json",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -21,9 +21,13 @@ import { maybeAddMarimoImport } from "@/core/cells/add-missing-import";
|
|
|
21
21
|
import { useCellActions } from "@/core/cells/cells";
|
|
22
22
|
import { useLastFocusedCellId } from "@/core/cells/focus";
|
|
23
23
|
import { autoInstantiateAtom } from "@/core/config/config";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
ENV_RENDERER,
|
|
26
|
+
SECRET_TEXTAREA_RENDERER,
|
|
27
|
+
SecretsProvider,
|
|
28
|
+
} from "./form-renderers";
|
|
25
29
|
|
|
26
|
-
const RENDERERS: FormRenderer[] = [ENV_RENDERER];
|
|
30
|
+
const RENDERERS: FormRenderer[] = [ENV_RENDERER, SECRET_TEXTAREA_RENDERER];
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* Grid layout for provider/database selector buttons.
|
package/src/components/editor/connections/database/__tests__/__snapshots__/as-code.test.ts.snap
CHANGED
|
@@ -4,7 +4,7 @@ exports[`generateDatabaseCode > basic connections > bigquery 1`] = `
|
|
|
4
4
|
"import json
|
|
5
5
|
import sqlmodel
|
|
6
6
|
|
|
7
|
-
credentials = json.loads("""{"type": "service_account", "project_id": "test"}""")
|
|
7
|
+
credentials = json.loads(r"""{"type": "service_account", "project_id": "test"}""")
|
|
8
8
|
engine = sqlmodel.create_engine(f"bigquery://my-project/my_dataset", credentials_info=credentials)"
|
|
9
9
|
`;
|
|
10
10
|
|
|
@@ -388,10 +388,26 @@ import sqlmodel
|
|
|
388
388
|
|
|
389
389
|
_project = os.environ.get("ENV_PROJECT")
|
|
390
390
|
_dataset = os.environ.get("ENV_DATASET")
|
|
391
|
-
credentials = json.loads("""{"type": "service_account", "project_id": "test"}""")
|
|
391
|
+
credentials = json.loads(r"""{"type": "service_account", "project_id": "test"}""")
|
|
392
392
|
engine = sqlmodel.create_engine(f"bigquery://{_project}/{_dataset}", credentials_info=credentials)"
|
|
393
393
|
`;
|
|
394
394
|
|
|
395
|
+
exports[`generateDatabaseCode > connections with secrets > bigquery with credentials_json as a file path 1`] = `
|
|
396
|
+
"import sqlmodel
|
|
397
|
+
|
|
398
|
+
engine = sqlmodel.create_engine(f"bigquery://my-project/my_dataset", credentials_path="/etc/secrets/bigquery.json")"
|
|
399
|
+
`;
|
|
400
|
+
|
|
401
|
+
exports[`generateDatabaseCode > connections with secrets > bigquery with credentials_json as secret 1`] = `
|
|
402
|
+
"import json
|
|
403
|
+
import os
|
|
404
|
+
import sqlmodel
|
|
405
|
+
|
|
406
|
+
_credentials_json = os.environ.get("BIGQUERY_CREDENTIALS_JSON")
|
|
407
|
+
credentials = json.loads(_credentials_json)
|
|
408
|
+
engine = sqlmodel.create_engine(f"bigquery://my-project/my_dataset", credentials_info=credentials)"
|
|
409
|
+
`;
|
|
410
|
+
|
|
395
411
|
exports[`generateDatabaseCode > connections with secrets > clickhouse with all connection details as secrets 1`] = `
|
|
396
412
|
"import clickhouse_connect
|
|
397
413
|
import os
|
|
@@ -493,7 +509,7 @@ exports[`generateDatabaseCode > edge cases > bigquery with long credentials 1`]
|
|
|
493
509
|
"import json
|
|
494
510
|
import sqlmodel
|
|
495
511
|
|
|
496
|
-
credentials = json.loads("""xxxxxxxxxx""")
|
|
512
|
+
credentials = json.loads(r"""xxxxxxxxxx""")
|
|
497
513
|
engine = sqlmodel.create_engine(f"bigquery://my-project/my_dataset", credentials_info=credentials)"
|
|
498
514
|
`;
|
|
499
515
|
|
|
@@ -735,7 +751,7 @@ exports[`generateDatabaseCode > security cases > bigquery with malformed JSON 1`
|
|
|
735
751
|
"import json
|
|
736
752
|
import sqlmodel
|
|
737
753
|
|
|
738
|
-
credentials = json.loads("""{"type": "service_account", "project_id": "test"""")
|
|
754
|
+
credentials = json.loads(r"""{"type": "service_account", "project_id": "test"""")
|
|
739
755
|
engine = sqlmodel.create_engine(f"bigquery://my-project/my_dataset", credentials_info=credentials)"
|
|
740
756
|
`;
|
|
741
757
|
|
|
@@ -743,7 +759,7 @@ exports[`generateDatabaseCode > security cases > bigquery with malformed JSON 2`
|
|
|
743
759
|
"import json
|
|
744
760
|
import sqlalchemy
|
|
745
761
|
|
|
746
|
-
credentials = json.loads("""{"type": "service_account", "project_id": "test"""")
|
|
762
|
+
credentials = json.loads(r"""{"type": "service_account", "project_id": "test"""")
|
|
747
763
|
engine = sqlalchemy.create_engine(f"bigquery://my-project/my_dataset", credentials_info=credentials)"
|
|
748
764
|
`;
|
|
749
765
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { prefixPath } from "../../paths";
|
|
3
4
|
import { prefixSecret } from "../../secrets";
|
|
4
5
|
import { type ConnectionLibrary, generateDatabaseCode } from "../as-code";
|
|
5
6
|
import type { DatabaseConnection } from "../schemas";
|
|
@@ -383,6 +384,22 @@ describe("generateDatabaseCode", () => {
|
|
|
383
384
|
},
|
|
384
385
|
"sqlmodel",
|
|
385
386
|
],
|
|
387
|
+
[
|
|
388
|
+
"bigquery with credentials_json as secret",
|
|
389
|
+
{
|
|
390
|
+
...bigqueryConnection,
|
|
391
|
+
credentials_json: prefixSecret("BIGQUERY_CREDENTIALS_JSON"),
|
|
392
|
+
},
|
|
393
|
+
"sqlmodel",
|
|
394
|
+
],
|
|
395
|
+
[
|
|
396
|
+
"bigquery with credentials_json as a file path",
|
|
397
|
+
{
|
|
398
|
+
...bigqueryConnection,
|
|
399
|
+
credentials_json: prefixPath("/etc/secrets/bigquery.json"),
|
|
400
|
+
},
|
|
401
|
+
"sqlmodel",
|
|
402
|
+
],
|
|
386
403
|
[
|
|
387
404
|
"clickhouse with all connection details as secrets",
|
|
388
405
|
{
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import dedent from "string-dedent";
|
|
4
4
|
import { assertNever } from "@/utils/assertNever";
|
|
5
|
+
import { escapePythonString, resolveJsonCredential } from "../json-credentials";
|
|
5
6
|
import { isSecret, unprefixSecret } from "../secrets";
|
|
6
7
|
import { type DatabaseConnection, DatabaseConnectionSchema } from "./schemas";
|
|
7
8
|
|
|
@@ -415,8 +416,14 @@ ${formatUrlParams(params, (inner) => ` ${inner}`)},
|
|
|
415
416
|
}
|
|
416
417
|
|
|
417
418
|
class BigQueryGenerator extends CodeGenerator<"bigquery"> {
|
|
419
|
+
private get credential() {
|
|
420
|
+
return resolveJsonCredential(this.connection.credentials_json, (v) =>
|
|
421
|
+
this.secrets.print("credentials_json", v),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
418
425
|
generateImports(): string[] {
|
|
419
|
-
return ["import json"];
|
|
426
|
+
return this.credential.kind === "path" ? [] : ["import json"];
|
|
420
427
|
}
|
|
421
428
|
|
|
422
429
|
generateConnectionCode(): string {
|
|
@@ -428,9 +435,16 @@ class BigQueryGenerator extends CodeGenerator<"bigquery"> {
|
|
|
428
435
|
"dataset",
|
|
429
436
|
this.connection.dataset,
|
|
430
437
|
);
|
|
438
|
+
const credential = this.credential;
|
|
439
|
+
|
|
440
|
+
if (credential.kind === "path") {
|
|
441
|
+
return dedent(`
|
|
442
|
+
engine = ${this.orm}.create_engine(f"bigquery://${project}/${dataset}", credentials_path="${escapePythonString(credential.path)}")
|
|
443
|
+
`);
|
|
444
|
+
}
|
|
431
445
|
|
|
432
446
|
return dedent(`
|
|
433
|
-
credentials =
|
|
447
|
+
credentials = ${credential.expr}
|
|
434
448
|
engine = ${this.orm}.create_engine(f"bigquery://${project}/${dataset}", credentials_info=credentials)
|
|
435
449
|
`);
|
|
436
450
|
}
|
|
@@ -295,11 +295,12 @@ export const BigQueryConnectionSchema = z
|
|
|
295
295
|
optionRegex: "(bigquery.?dataset|dataset)",
|
|
296
296
|
}),
|
|
297
297
|
),
|
|
298
|
-
credentials_json: z
|
|
299
|
-
.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
),
|
|
298
|
+
credentials_json: z.string().describe(
|
|
299
|
+
FieldOptions.of({
|
|
300
|
+
label: "Credentials JSON",
|
|
301
|
+
special: "secret_textarea",
|
|
302
|
+
}),
|
|
303
|
+
),
|
|
303
304
|
})
|
|
304
305
|
.describe(FieldOptions.of({ direction: "two-columns" }));
|
|
305
306
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import { InfoIcon } from "lucide-react";
|
|
3
4
|
import { createContext, type ReactNode, use, useMemo } from "react";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import type { FormRenderer } from "@/components/forms/form";
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
FormLabel,
|
|
14
15
|
FormMessage,
|
|
15
16
|
} from "@/components/ui/form";
|
|
17
|
+
import { Tooltip } from "@/components/ui/tooltip";
|
|
16
18
|
import { SECRETS_REGISTRY } from "@/core/secrets/request-registry";
|
|
17
19
|
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
18
20
|
import { Functions } from "@/utils/functions";
|
|
@@ -20,8 +22,10 @@ import {
|
|
|
20
22
|
sortProviders,
|
|
21
23
|
WriteSecretModal,
|
|
22
24
|
} from "../chrome/panels/write-secret-modal";
|
|
25
|
+
import { looksLikeJson } from "./json-credentials";
|
|
26
|
+
import { isPath, prefixPath, unprefixPath } from "./paths";
|
|
23
27
|
import { partitionSecretKeys, SecretCombobox } from "./secret-combobox";
|
|
24
|
-
import { prefixSecret } from "./secrets";
|
|
28
|
+
import { isSecret, prefixSecret } from "./secrets";
|
|
25
29
|
|
|
26
30
|
interface SecretsContextType {
|
|
27
31
|
providerNames: string[];
|
|
@@ -141,3 +145,97 @@ export const ENV_RENDERER: FormRenderer<z.ZodString> = {
|
|
|
141
145
|
);
|
|
142
146
|
},
|
|
143
147
|
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* For credentials that shouldn't be baked into the generated notebook code
|
|
151
|
+
* (e.g. a service-account JSON key).
|
|
152
|
+
*/
|
|
153
|
+
export const SECRET_TEXTAREA_RENDERER: FormRenderer<z.ZodString> = {
|
|
154
|
+
isMatch: (schema: z.ZodType): schema is z.ZodString => {
|
|
155
|
+
if (schema instanceof z.ZodString) {
|
|
156
|
+
const { special } = FieldOptions.parse(schema.description || "");
|
|
157
|
+
return special === "secret_textarea";
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
},
|
|
161
|
+
Component: ({ schema, form, path }) => {
|
|
162
|
+
const { secretKeys, providerNames, refreshSecrets } = useSecrets();
|
|
163
|
+
const { openModal, closeModal } = useImperativeModal();
|
|
164
|
+
const { label, description, placeholder } = FieldOptions.parse(
|
|
165
|
+
schema.description || "",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<FormField
|
|
170
|
+
control={form.control}
|
|
171
|
+
name={path}
|
|
172
|
+
render={({ field }) => {
|
|
173
|
+
const value = field.value ? String(field.value) : "";
|
|
174
|
+
// Non-secret values are file paths; display them unprefixed.
|
|
175
|
+
const displayValue = isPath(value) ? unprefixPath(value) : value;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<FormItem>
|
|
179
|
+
<FormLabel className="flex items-center gap-1">
|
|
180
|
+
{label}
|
|
181
|
+
<Tooltip
|
|
182
|
+
content="Enter a path to a file on disk, or create a secret to paste JSON directly."
|
|
183
|
+
delayDuration={300}
|
|
184
|
+
>
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
className="inline-flex"
|
|
188
|
+
aria-label="Credential input help"
|
|
189
|
+
>
|
|
190
|
+
<InfoIcon className="h-3.5 w-3.5" />
|
|
191
|
+
</button>
|
|
192
|
+
</Tooltip>
|
|
193
|
+
</FormLabel>
|
|
194
|
+
<FormDescription>{description}</FormDescription>
|
|
195
|
+
<FormControl>
|
|
196
|
+
<SecretCombobox
|
|
197
|
+
value={displayValue}
|
|
198
|
+
onChange={(next) => {
|
|
199
|
+
field.onChange(
|
|
200
|
+
!next || isSecret(next) || isPath(next)
|
|
201
|
+
? next
|
|
202
|
+
: prefixPath(next),
|
|
203
|
+
);
|
|
204
|
+
}}
|
|
205
|
+
placeholder={placeholder}
|
|
206
|
+
searchPlaceholder="Type a file path or paste JSON"
|
|
207
|
+
formatCustomValueLabel={(custom) =>
|
|
208
|
+
`Use "${custom}" as a file path`
|
|
209
|
+
}
|
|
210
|
+
createSecretLabel="Paste JSON credentials"
|
|
211
|
+
allowCustomValue={(search) => !looksLikeJson(search)}
|
|
212
|
+
allowCreateSecret={(search) =>
|
|
213
|
+
!search || looksLikeJson(search)
|
|
214
|
+
}
|
|
215
|
+
recommendedKeys={[]}
|
|
216
|
+
otherKeys={secretKeys}
|
|
217
|
+
onCreateSecret={(suggestedValue) => {
|
|
218
|
+
openModal(
|
|
219
|
+
<WriteSecretModal
|
|
220
|
+
providerNames={providerNames}
|
|
221
|
+
initialValue={suggestedValue}
|
|
222
|
+
multiline={true}
|
|
223
|
+
onSuccess={(secretKey) => {
|
|
224
|
+
refreshSecrets();
|
|
225
|
+
field.onChange(prefixSecret(secretKey));
|
|
226
|
+
closeModal();
|
|
227
|
+
}}
|
|
228
|
+
onClose={closeModal}
|
|
229
|
+
/>,
|
|
230
|
+
);
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
</FormControl>
|
|
234
|
+
<FormMessage />
|
|
235
|
+
</FormItem>
|
|
236
|
+
);
|
|
237
|
+
}}
|
|
238
|
+
/>
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { isPath, unprefixPath } from "./paths";
|
|
3
|
+
import { isSecret } from "./secrets";
|
|
4
|
+
|
|
5
|
+
export type JsonCredential =
|
|
6
|
+
| { kind: "path"; path: string }
|
|
7
|
+
| { kind: "json"; expr: string };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves a `secret_textarea` field's value — a file path, a secret
|
|
11
|
+
* reference, or a raw literal — into either a path to hand to a
|
|
12
|
+
* provider's path-based auth kwarg, or a `json.loads(...)` expression.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveJsonCredential(
|
|
15
|
+
value: string,
|
|
16
|
+
printSecret: (value: string) => string,
|
|
17
|
+
): JsonCredential {
|
|
18
|
+
if (isPath(value)) {
|
|
19
|
+
return { kind: "path", path: unprefixPath(value) };
|
|
20
|
+
}
|
|
21
|
+
const expr = isSecret(value)
|
|
22
|
+
? `json.loads(${printSecret(value)})`
|
|
23
|
+
: // Use raw string to avoid interpreting \n escapes as newlines.
|
|
24
|
+
`json.loads(r"""${value}""")`;
|
|
25
|
+
return { kind: "json", expr };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function looksLikeJson(value: string): boolean {
|
|
29
|
+
const trimmed = value.trimStart();
|
|
30
|
+
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Collapse a credential value to a single dotenv-safe line: minify JSON
|
|
35
|
+
* when parseable, otherwise strip newlines.
|
|
36
|
+
*/
|
|
37
|
+
export function flattenSecretValue(value: string): string {
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
if (!trimmed) {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return JSON.stringify(JSON.parse(trimmed));
|
|
44
|
+
} catch {
|
|
45
|
+
return value.replaceAll(/\r?\n/g, "");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Escape a filesystem path for embedding in a Python double-quoted string. */
|
|
50
|
+
export function escapePythonString(value: string): string {
|
|
51
|
+
return value
|
|
52
|
+
.replaceAll("\\", "\\\\")
|
|
53
|
+
.replaceAll('"', '\\"')
|
|
54
|
+
.replaceAll("\n", "\\n")
|
|
55
|
+
.replaceAll("\r", "\\r")
|
|
56
|
+
.replaceAll("\t", "\\t");
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type { TypedString } from "@/utils/typed";
|
|
3
|
+
|
|
4
|
+
const PREFIX = "path:";
|
|
5
|
+
|
|
6
|
+
export type PathPlaceholder = TypedString<"path">;
|
|
7
|
+
|
|
8
|
+
export function isPath(value: unknown): value is PathPlaceholder {
|
|
9
|
+
if (typeof value !== "string") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return value.startsWith(PREFIX);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function prefixPath(value: string): PathPlaceholder {
|
|
16
|
+
return `${PREFIX}${value}` as PathPlaceholder;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function unprefixPath(value: PathPlaceholder): string {
|
|
20
|
+
return value.slice(PREFIX.length);
|
|
21
|
+
}
|
|
@@ -56,6 +56,16 @@ interface SecretComboboxProps {
|
|
|
56
56
|
/** Opens the create-secret flow; `suggestedValue` is the current search text when present. */
|
|
57
57
|
onCreateSecret: (suggestedValue?: string) => void;
|
|
58
58
|
className?: string;
|
|
59
|
+
/** Placeholder for the search input inside the popover. */
|
|
60
|
+
searchPlaceholder?: string;
|
|
61
|
+
/** Label for the "commit a free-text literal" item, e.g. `Use "${value}" as a path`. */
|
|
62
|
+
formatCustomValueLabel?: (value: string) => string;
|
|
63
|
+
/** Label for the "create a new secret" item. */
|
|
64
|
+
createSecretLabel?: string;
|
|
65
|
+
/** When false for the current search, hide the free-text / path option. */
|
|
66
|
+
allowCustomValue?: (search: string) => boolean;
|
|
67
|
+
/** When false for the current search, hide the "create a new secret" option. */
|
|
68
|
+
allowCreateSecret?: (search: string) => boolean;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -73,6 +83,11 @@ export const SecretCombobox: React.FC<SecretComboboxProps> = ({
|
|
|
73
83
|
otherKeys,
|
|
74
84
|
onCreateSecret,
|
|
75
85
|
className,
|
|
86
|
+
searchPlaceholder,
|
|
87
|
+
formatCustomValueLabel = (custom) => `Use "${custom}"`,
|
|
88
|
+
createSecretLabel = "Create a new secret",
|
|
89
|
+
allowCustomValue,
|
|
90
|
+
allowCreateSecret,
|
|
76
91
|
}) => {
|
|
77
92
|
const [open, setOpen] = useState(false);
|
|
78
93
|
const [search, setSearch] = useState("");
|
|
@@ -81,7 +96,11 @@ export const SecretCombobox: React.FC<SecretComboboxProps> = ({
|
|
|
81
96
|
// Non-secret fields may commit a literal even when it collides with an
|
|
82
97
|
// existing secret key, so we intentionally don't filter out matches here.
|
|
83
98
|
const showCustomValue =
|
|
84
|
-
!secretsOnly &&
|
|
99
|
+
!secretsOnly &&
|
|
100
|
+
trimmedSearch.length > 0 &&
|
|
101
|
+
trimmedSearch !== value &&
|
|
102
|
+
(allowCustomValue?.(trimmedSearch) ?? true);
|
|
103
|
+
const showCreateSecret = allowCreateSecret?.(trimmedSearch) ?? true;
|
|
85
104
|
|
|
86
105
|
const displayValue = (() => {
|
|
87
106
|
if (!value) {
|
|
@@ -158,9 +177,10 @@ export const SecretCombobox: React.FC<SecretComboboxProps> = ({
|
|
|
158
177
|
<Command>
|
|
159
178
|
<CommandInput
|
|
160
179
|
placeholder={
|
|
161
|
-
|
|
180
|
+
searchPlaceholder ??
|
|
181
|
+
(secretsOnly
|
|
162
182
|
? "Search secrets..."
|
|
163
|
-
: "Type a value or search secrets..."
|
|
183
|
+
: "Type a value or search secrets...")
|
|
164
184
|
}
|
|
165
185
|
rootClassName="px-1 h-8"
|
|
166
186
|
autoFocus={true}
|
|
@@ -174,26 +194,27 @@ export const SecretCombobox: React.FC<SecretComboboxProps> = ({
|
|
|
174
194
|
value={`use custom value ${trimmedSearch}`}
|
|
175
195
|
onSelect={() => selectCustom(trimmedSearch)}
|
|
176
196
|
>
|
|
177
|
-
|
|
197
|
+
{formatCustomValueLabel(trimmedSearch)}
|
|
198
|
+
</CommandItem>
|
|
199
|
+
</CommandGroup>
|
|
200
|
+
)}
|
|
201
|
+
{showCreateSecret && (
|
|
202
|
+
<CommandGroup className="mt-0">
|
|
203
|
+
<CommandItem
|
|
204
|
+
// Include search so this stays visible while filtering
|
|
205
|
+
value={`create new secret ${trimmedSearch}`}
|
|
206
|
+
onSelect={() => {
|
|
207
|
+
const suggestedValue = trimmedSearch || undefined;
|
|
208
|
+
setOpen(false);
|
|
209
|
+
setSearch("");
|
|
210
|
+
onCreateSecret(suggestedValue);
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<PlusCircleIcon className="mr-2 h-3.5 w-3.5" />
|
|
214
|
+
{createSecretLabel}
|
|
178
215
|
</CommandItem>
|
|
179
216
|
</CommandGroup>
|
|
180
217
|
)}
|
|
181
|
-
{showCustomValue && <CommandSeparator />}
|
|
182
|
-
<CommandGroup className="mt-0">
|
|
183
|
-
<CommandItem
|
|
184
|
-
// Include search so this stays visible while filtering
|
|
185
|
-
value={`create new secret ${trimmedSearch}`}
|
|
186
|
-
onSelect={() => {
|
|
187
|
-
const suggestedValue = trimmedSearch || undefined;
|
|
188
|
-
setOpen(false);
|
|
189
|
-
setSearch("");
|
|
190
|
-
onCreateSecret(suggestedValue);
|
|
191
|
-
}}
|
|
192
|
-
>
|
|
193
|
-
<PlusCircleIcon className="mr-2 h-3.5 w-3.5" />
|
|
194
|
-
Create a new secret
|
|
195
|
-
</CommandItem>
|
|
196
|
-
</CommandGroup>
|
|
197
218
|
{recommendedKeys.length > 0 && (
|
|
198
219
|
<>
|
|
199
220
|
<CommandSeparator className="mt-0" />
|
package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap
CHANGED
|
@@ -85,7 +85,27 @@ exports[`generateStorageCode > GCS > with service account key 1`] = `
|
|
|
85
85
|
"from obstore.store import GCSStore
|
|
86
86
|
import json
|
|
87
87
|
|
|
88
|
-
_credentials = json.loads("""{"type": "service_account", "project_id": "test"}""")
|
|
88
|
+
_credentials = json.loads(r"""{"type": "service_account", "project_id": "test"}""")
|
|
89
|
+
store = GCSStore("my-bucket",
|
|
90
|
+
service_account_key=_credentials,
|
|
91
|
+
)"
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
exports[`generateStorageCode > GCS > with service account key from a file path 1`] = `
|
|
95
|
+
"from obstore.store import GCSStore
|
|
96
|
+
|
|
97
|
+
store = GCSStore("my-bucket",
|
|
98
|
+
service_account_path="/etc/secrets/gcs.json",
|
|
99
|
+
)"
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
exports[`generateStorageCode > GCS > with service account key from secrets 1`] = `
|
|
103
|
+
"from obstore.store import GCSStore
|
|
104
|
+
import json
|
|
105
|
+
import os
|
|
106
|
+
|
|
107
|
+
_service_account_key = os.environ.get("GCS_SERVICE_ACCOUNT_KEY")
|
|
108
|
+
_credentials = json.loads(_service_account_key)
|
|
89
109
|
store = GCSStore("my-bucket",
|
|
90
110
|
service_account_key=_credentials,
|
|
91
111
|
)"
|
|
@@ -114,7 +134,23 @@ exports[`generateStorageCode > Google Drive > with service account credentials 1
|
|
|
114
134
|
"from gdrive_fsspec import GoogleDriveFileSystem
|
|
115
135
|
import json
|
|
116
136
|
|
|
117
|
-
_creds = json.loads("""{"type": "service_account", "client_email": "test@test.iam.gserviceaccount.com"}""")
|
|
137
|
+
_creds = json.loads(r"""{"type": "service_account", "client_email": "test@test.iam.gserviceaccount.com"}""")
|
|
138
|
+
fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False, skip_instance_cache=True)"
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
exports[`generateStorageCode > Google Drive > with service account credentials from a file path 1`] = `
|
|
142
|
+
"from gdrive_fsspec import GoogleDriveFileSystem
|
|
143
|
+
|
|
144
|
+
fs = GoogleDriveFileSystem(creds="/etc/secrets/gdrive.json", token="service_account", use_listings_cache=False, skip_instance_cache=True)"
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
exports[`generateStorageCode > Google Drive > with service account credentials from secrets 1`] = `
|
|
148
|
+
"from gdrive_fsspec import GoogleDriveFileSystem
|
|
149
|
+
import json
|
|
150
|
+
import os
|
|
151
|
+
|
|
152
|
+
_credentials_json = os.environ.get("GDRIVE_CREDENTIALS_JSON")
|
|
153
|
+
_creds = json.loads(_credentials_json)
|
|
118
154
|
fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False, skip_instance_cache=True)"
|
|
119
155
|
`;
|
|
120
156
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { prefixPath } from "../../paths";
|
|
3
4
|
import { prefixSecret } from "../../secrets";
|
|
4
5
|
import { generateStorageCode } from "../as-code";
|
|
5
6
|
import type { StorageConnection } from "../schemas";
|
|
@@ -155,6 +156,26 @@ describe("generateStorageCode", () => {
|
|
|
155
156
|
generateStorageCode(conn, { library: "obstore" }),
|
|
156
157
|
).toMatchSnapshot();
|
|
157
158
|
});
|
|
159
|
+
|
|
160
|
+
it("with service account key from secrets", () => {
|
|
161
|
+
const conn: StorageConnection = {
|
|
162
|
+
...baseGCS,
|
|
163
|
+
service_account_key: prefixSecret("GCS_SERVICE_ACCOUNT_KEY"),
|
|
164
|
+
};
|
|
165
|
+
expect(
|
|
166
|
+
generateStorageCode(conn, { library: "obstore" }),
|
|
167
|
+
).toMatchSnapshot();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("with service account key from a file path", () => {
|
|
171
|
+
const conn: StorageConnection = {
|
|
172
|
+
...baseGCS,
|
|
173
|
+
service_account_key: prefixPath("/etc/secrets/gcs.json"),
|
|
174
|
+
};
|
|
175
|
+
expect(
|
|
176
|
+
generateStorageCode(conn, { library: "obstore" }),
|
|
177
|
+
).toMatchSnapshot();
|
|
178
|
+
});
|
|
158
179
|
});
|
|
159
180
|
|
|
160
181
|
describe("Azure", () => {
|
|
@@ -266,6 +287,26 @@ describe("generateStorageCode", () => {
|
|
|
266
287
|
generateStorageCode(conn, { library: "fsspec", isEmbedded: true }),
|
|
267
288
|
).toMatchSnapshot();
|
|
268
289
|
});
|
|
290
|
+
|
|
291
|
+
it("with service account credentials from secrets", () => {
|
|
292
|
+
const conn: StorageConnection = {
|
|
293
|
+
...baseGDrive,
|
|
294
|
+
credentials_json: prefixSecret("GDRIVE_CREDENTIALS_JSON"),
|
|
295
|
+
};
|
|
296
|
+
expect(
|
|
297
|
+
generateStorageCode(conn, { library: "fsspec" }),
|
|
298
|
+
).toMatchSnapshot();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("with service account credentials from a file path", () => {
|
|
302
|
+
const conn: StorageConnection = {
|
|
303
|
+
...baseGDrive,
|
|
304
|
+
credentials_json: prefixPath("/etc/secrets/gdrive.json"),
|
|
305
|
+
};
|
|
306
|
+
expect(
|
|
307
|
+
generateStorageCode(conn, { library: "fsspec" }),
|
|
308
|
+
).toMatchSnapshot();
|
|
309
|
+
});
|
|
269
310
|
});
|
|
270
311
|
|
|
271
312
|
describe("invalid cases", () => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import dedent from "string-dedent";
|
|
4
4
|
import { assertNever } from "@/utils/assertNever";
|
|
5
|
+
import { escapePythonString, resolveJsonCredential } from "../json-credentials";
|
|
5
6
|
import { isSecret, unprefixSecret } from "../secrets";
|
|
6
7
|
import {
|
|
7
8
|
type S3Auth,
|
|
@@ -120,20 +121,36 @@ function generateGCSCode(
|
|
|
120
121
|
const bucket = secrets.print("bucket", connection.bucket);
|
|
121
122
|
const imports = new Set(["from obstore.store import GCSStore"]);
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
if (!connection.service_account_key) {
|
|
125
|
+
return {
|
|
126
|
+
imports,
|
|
127
|
+
code: dedent(`
|
|
128
|
+
store = GCSStore(${bucket})
|
|
129
|
+
`),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const credential = resolveJsonCredential(
|
|
134
|
+
connection.service_account_key,
|
|
135
|
+
(v) => secrets.print("service_account_key", v),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (credential.kind === "path") {
|
|
139
|
+
const code = dedent(`
|
|
128
140
|
store = GCSStore(${bucket},
|
|
129
|
-
|
|
141
|
+
service_account_path="${escapePythonString(credential.path)}",
|
|
130
142
|
)
|
|
131
143
|
`);
|
|
132
|
-
|
|
133
|
-
code = dedent(`
|
|
134
|
-
store = GCSStore(${bucket})
|
|
135
|
-
`);
|
|
144
|
+
return { imports, code };
|
|
136
145
|
}
|
|
146
|
+
|
|
147
|
+
imports.add("import json");
|
|
148
|
+
const code = dedent(`
|
|
149
|
+
_credentials = ${credential.expr}
|
|
150
|
+
store = GCSStore(${bucket},
|
|
151
|
+
service_account_key=_credentials,
|
|
152
|
+
)
|
|
153
|
+
`);
|
|
137
154
|
return { imports, code };
|
|
138
155
|
}
|
|
139
156
|
|
|
@@ -195,13 +212,20 @@ function generateGDriveCode(
|
|
|
195
212
|
const imports = new Set(["from gdrive_fsspec import GoogleDriveFileSystem"]);
|
|
196
213
|
|
|
197
214
|
if (connection.credentials_json) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"credentials_json",
|
|
201
|
-
connection.credentials_json,
|
|
215
|
+
const credential = resolveJsonCredential(connection.credentials_json, (v) =>
|
|
216
|
+
secrets.print("credentials_json", v),
|
|
202
217
|
);
|
|
218
|
+
// gdrive-fsspec accepts a filesystem path as `creds` when the string
|
|
219
|
+
// doesn't start with "{"; otherwise it expects a JSON object/string.
|
|
220
|
+
if (credential.kind === "path") {
|
|
221
|
+
const code = dedent(`
|
|
222
|
+
fs = GoogleDriveFileSystem(creds="${escapePythonString(credential.path)}", token="service_account", use_listings_cache=False, skip_instance_cache=True)
|
|
223
|
+
`);
|
|
224
|
+
return { imports, code };
|
|
225
|
+
}
|
|
226
|
+
imports.add("import json");
|
|
203
227
|
const code = dedent(`
|
|
204
|
-
_creds =
|
|
228
|
+
_creds = ${credential.expr}
|
|
205
229
|
fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False, skip_instance_cache=True)
|
|
206
230
|
`);
|
|
207
231
|
return { imports, code };
|
|
@@ -138,7 +138,7 @@ export const GCSStorageSchema = z
|
|
|
138
138
|
.describe(
|
|
139
139
|
FieldOptions.of({
|
|
140
140
|
label: "Service Account Key (JSON)",
|
|
141
|
-
|
|
141
|
+
special: "secret_textarea",
|
|
142
142
|
}),
|
|
143
143
|
),
|
|
144
144
|
})
|
|
@@ -214,7 +214,7 @@ export const GoogleDriveStorageSchema = z
|
|
|
214
214
|
FieldOptions.of({
|
|
215
215
|
label: "Service Account JSON",
|
|
216
216
|
description: "Leave empty to use browser-based authentication",
|
|
217
|
-
|
|
217
|
+
special: "secret_textarea",
|
|
218
218
|
}),
|
|
219
219
|
),
|
|
220
220
|
})
|