feat: add fileset resource type support

Add a new "fileset" resource type that represents a collection of files
stored as a relpath→content map. This enables resource types to manage
multiple files (e.g., config directories, template sets) instead of just
a single file.

Backend:
- Add is_fileset column to resource_type table
- Update CRUD operations and workspace duplication to handle is_fileset
- Add integration tests for fileset resource types

Frontend:
- Add FilesetEditor component with file explorer + Monaco editor
- Extract shared FileExplorer component from RawAppSidebar (dedup)
- Add fileset toggle to EditableSchemaWrapper
- Show fileset editor in ResourceEditor and ApiConnectForm
- Show folder icon for fileset resource types in IconedResourceType

CLI:
- Support fileset resources in sync pull (expand to .fileset/ directory)
- Support fileset resources in sync push (reconstruct from directory)
- Handle !inline_fileset YAML tag in resource resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-23 17:50:37 +00:00
parent 710809ac64
commit d4dc0a088f
575 changed files with 1159 additions and 14866 deletions

View File

@@ -1,5 +1,6 @@
import { stat, writeFile } from "node:fs/promises";
import { stat, writeFile, readdir, readFile } from "node:fs/promises";
import { stringify as yamlStringify } from "yaml";
import nodePath from "node:path";
import {
GlobalOptions,
@@ -27,6 +28,24 @@ export interface ResourceFile {
is_oauth?: boolean; // deprecated
}
async function readFilesetDirectory(dirPath: string): Promise<Record<string, string>> {
const result: Record<string, string> = {};
async function walk(currentPath: string, prefix: string) {
const entries = await readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = nodePath.join(currentPath, entry.name);
const relPath = prefix ? prefix + "/" + entry.name : entry.name;
if (entry.isDirectory()) {
await walk(entryPath, relPath);
} else if (entry.isFile()) {
result[relPath] = await readFile(entryPath, "utf-8");
}
}
}
await walk(dirPath, "");
return result;
}
export async function pushResource(
workspace: string,
remotePath: string,
@@ -46,7 +65,10 @@ export async function pushResource(
// Helper function to resolve inline content
const resolveInlineContent = async () => {
if (localResource.value["content"]?.startsWith("!inline ")) {
if (typeof localResource.value === "string" && localResource.value.startsWith("!inline_fileset ")) {
const dirPath = localResource.value.split(" ")[1];
localResource.value = await readFilesetDirectory(dirPath.replaceAll("/", SEP));
} else if (localResource.value["content"]?.startsWith("!inline ")) {
const basePath = localResource.value["content"].split(" ")[1];
// If we're processing a branch-specific metadata file, read from branch-specific resource file

View File

@@ -38,6 +38,7 @@ import {
deepEqual,
fetchRemoteVersion,
isFileResource,
isFilesetResource,
isRawAppFile,
isWorkspaceDependencies,
} from "../../utils/utils.ts";
@@ -484,11 +485,53 @@ export function extractInlineScriptsForApps(
return [];
}
type FileResourceTypeInfo = { format_extension: string | null; is_fileset: boolean };
function parseFileResourceTypeMap(
raw: Record<string, string | FileResourceTypeInfo>,
): { formatExtMap: Record<string, string>; filesetMap: Record<string, boolean> } {
const formatExtMap: Record<string, string> = {};
const filesetMap: Record<string, boolean> = {};
for (const [k, v] of Object.entries(raw)) {
if (typeof v === "string") {
formatExtMap[k] = v;
filesetMap[k] = false;
} else {
if (v.format_extension) {
formatExtMap[k] = v.format_extension;
}
filesetMap[k] = v.is_fileset ?? false;
}
}
return { formatExtMap, filesetMap };
}
async function findFilesetResourceFile(changePath: string): Promise<string> {
// Extract the base path before .fileset/
const filesetIdx = changePath.indexOf(".fileset" + SEP);
if (filesetIdx === -1) {
throw new Error(`Not a fileset resource path: ${changePath}`);
}
const basePath = changePath.substring(0, filesetIdx);
const candidates = [basePath + ".resource.json", basePath + ".resource.yaml"];
for (const candidate of candidates) {
try {
const s = await stat(candidate);
if (s.isFile()) return candidate;
} catch {
// not found, try next
}
}
throw new Error(`No resource metadata file found for fileset resource: ${changePath}`);
}
function ZipFSElement(
zip: JSZip,
useYaml: boolean,
defaultTs: "bun" | "deno",
resourceTypeToFormatExtension: Record<string, string>,
resourceTypeToIsFileset: Record<string, boolean>,
ignoreCodebaseChanges: boolean,
): DynFSElement {
async function _internal_file(
@@ -860,10 +903,17 @@ function ZipFSElement(
log.error(`Failed to parse resource.yaml at path: ${p}`);
throw error;
}
const resourceType = parsed["resource_type"];
const formatExtension =
resourceTypeToFormatExtension[parsed["resource_type"]];
resourceTypeToFormatExtension[resourceType];
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
if (formatExtension) {
if (isFileset) {
parsed["value"] =
"!inline_fileset " +
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
".fileset";
} else if (formatExtension) {
parsed["value"]["content"] =
"!inline " +
removeSuffix(p.replaceAll(SEP, "/"), ".resource.json") +
@@ -918,10 +968,37 @@ function ZipFSElement(
log.error(`Failed to parse resource file content at path: ${p}`);
throw error;
}
const resourceType = parsed["resource_type"];
const formatExtension =
resourceTypeToFormatExtension[parsed["resource_type"]];
resourceTypeToFormatExtension[resourceType];
const isFileset = resourceTypeToIsFileset[resourceType] ?? false;
if (formatExtension) {
if (isFileset && typeof parsed["value"] === "object" && parsed["value"] !== null) {
const filesetBasePath =
removeSuffix(finalPath, ".resource.json") + ".fileset";
// Push directory entry for the fileset
r.push({
isDirectory: true,
path: filesetBasePath,
async *getChildren() {
for (const [relPath, fileContent] of Object.entries(parsed["value"])) {
if (typeof fileContent === "string") {
yield {
isDirectory: false,
path: path.join(filesetBasePath, relPath),
async *getChildren() {},
async getContentText() {
return fileContent;
},
};
}
}
},
async getContentText() {
throw new Error("Cannot get content of directory");
},
});
} else if (formatExtension) {
const fileContent: string = parsed["value"]["content"];
if (typeof fileContent === "string") {
r.push({
@@ -1058,6 +1135,7 @@ export async function elementsToMap(
const path = entry.path;
if (
!isFileResource(path) &&
!isFilesetResource(path) &&
!isRawAppFile(path) &&
!isWorkspaceDependencies(path)
) {
@@ -1103,7 +1181,7 @@ export async function elementsToMap(
}
}
if (skips.skipResources && isFileResource(path)) continue;
if (skips.skipResources && (isFileResource(path) || isFilesetResource(path))) continue;
const ext = json ? ".json" : ".yaml";
if (!skips.includeSchedules && path.endsWith(".schedule" + ext)) continue;
@@ -1715,10 +1793,14 @@ export async function pull(
);
let resourceTypeToFormatExtension: Record<string, string> = {};
let resourceTypeToIsFileset: Record<string, boolean> = {};
try {
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
const raw = (await wmill.fileResourceTypeToFileExtMap({
workspace: workspace.workspaceId,
})) as Record<string, string>;
})) as Record<string, string | FileResourceTypeInfo>;
const parsed = parseFileResourceTypeMap(raw);
resourceTypeToFormatExtension = parsed.formatExtMap;
resourceTypeToIsFileset = parsed.filesetMap;
} catch {
// ignore
}
@@ -1745,6 +1827,7 @@ export async function pull(
!opts.json,
opts.defaultTs ?? "bun",
resourceTypeToFormatExtension,
resourceTypeToIsFileset,
true,
);
@@ -2241,10 +2324,14 @@ export async function push(
),
);
let resourceTypeToFormatExtension: Record<string, string> = {};
let resourceTypeToIsFileset: Record<string, boolean> = {};
try {
resourceTypeToFormatExtension = (await wmill.fileResourceTypeToFileExtMap({
const raw = (await wmill.fileResourceTypeToFileExtMap({
workspace: workspace.workspaceId,
})) as Record<string, string>;
})) as Record<string, string | FileResourceTypeInfo>;
const parsed = parseFileResourceTypeMap(raw);
resourceTypeToFormatExtension = parsed.formatExtMap;
resourceTypeToIsFileset = parsed.filesetMap;
} catch {
// ignore
}
@@ -2269,6 +2356,7 @@ export async function push(
!opts.json,
opts.defaultTs ?? "bun",
resourceTypeToFormatExtension,
resourceTypeToIsFileset,
false,
);
@@ -2587,6 +2675,39 @@ export async function push(
continue;
}
}
if (isFilesetResource(change.path)) {
const resourceFilePath = await findFilesetResourceFile(change.path);
if (!alreadySynced.includes(resourceFilePath)) {
alreadySynced.push(resourceFilePath);
const newObj = parseFromPath(
resourceFilePath,
await readFile(resourceFilePath, "utf-8"),
);
let serverPath = resourceFilePath;
const currentBranch = cachedBranchForPush;
if (currentBranch && isBranchSpecificFile(resourceFilePath)) {
serverPath = fromBranchSpecificPath(
resourceFilePath,
currentBranch,
);
}
await pushResource(
workspace.workspaceId,
serverPath,
undefined,
newObj,
resourceFilePath,
);
if (stateTarget) {
await writeFile(stateTarget, change.after, "utf-8");
}
continue;
}
}
const oldObj = parseFromPath(change.path, change.before);
const newObj = parseFromPath(change.path, change.after);
@@ -2619,7 +2740,8 @@ export async function push(
change.path.endsWith(".script.json") ||
change.path.endsWith(".script.yaml") ||
change.path.endsWith(".lock") ||
isFileResource(change.path)
isFileResource(change.path) ||
isFilesetResource(change.path)
) {
continue;
} else if (

View File

@@ -1,6 +1,6 @@
import { minimatch } from "minimatch";
import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts";
import { isFileResource } from "../utils/utils.ts";
import { isFileResource, isFilesetResource } from "../utils/utils.ts";
import { SyncOptions } from "./conf.ts";
import { TRIGGER_TYPES } from "../types.ts";
@@ -165,7 +165,7 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC
return specificItems.settings !== undefined;
}
if (isFileResource(path)) {
if (isFileResource(path) || isFilesetResource(path)) {
return specificItems.resources !== undefined;
}
@@ -219,6 +219,14 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig
}
}
if (isFilesetResource(path)) {
const basePathMatch = path.match(/^(.+?)\.fileset[/\\]/);
if (basePathMatch && specificItems.resources) {
const basePath = basePathMatch[1] + '.resource.yaml';
return matchesPatterns(basePath, specificItems.resources);
}
}
return false;
}

View File

@@ -14,7 +14,7 @@ import { pushResourceType } from "./commands/resource-type/resource-type.ts";
import { pushVariable } from "./commands/variable/variable.ts";
import { yamlOptions } from "./commands/sync/sync.ts";
import { showDiffs } from "./core/conf.ts";
import { deepEqual, isFileResource, isWorkspaceDependencies } from "./utils/utils.ts";
import { deepEqual, isFileResource, isFilesetResource, isWorkspaceDependencies } from "./utils/utils.ts";
import { pushSchedule } from "./commands/schedule/schedule.ts";
import { pushWorkspaceUser } from "./commands/user/user.ts";
import { pushGroup } from "./commands/user/user.ts";
@@ -333,7 +333,7 @@ export function getTypeStrFromPath(
) {
return typeEnding;
} else {
if (isFileResource(p)) {
if (isFileResource(p) || isFilesetResource(p)) {
return "resource";
}
throw new Error("Could not infer type of path " + JSON.stringify(parsed));

View File

@@ -154,6 +154,10 @@ export function isFileResource(path: string): boolean {
);
}
export function isFilesetResource(path: string): boolean {
return path.includes(".fileset/") || path.includes(".fileset\\");
}
export function isRawAppFile(path: string): boolean {
return isRawAppPath(path);
}