683fc62129
- schema: userDotfiles (one encrypted row per file, HOME-relative path) and
userEnvironment (home username + optional public dotfiles repo + setup command)
- userDotfiles.ts: list/remove/removeDirectory/rename + internal upsert/getRaw
- userDotfilesNode.ts ('use node'): putFile/importFiles/getFileContent (encrypt
/decrypt via secretCrypto) + getEnvironmentForJob (worker-token, returns the
owner's decrypted dotfiles + repo/setup config)
- userEnvironment.ts: getMine/updateMine + getRawEnvironmentForJobInternal
- model.ts: deriveHomeUsername + normalizeDotfilePath helpers
122 lines
3.8 KiB
TypeScript
122 lines
3.8 KiB
TypeScript
import { ConvexError, v } from 'convex/values';
|
|
|
|
import type { Doc } from './_generated/dataModel';
|
|
import {
|
|
internalMutation,
|
|
internalQuery,
|
|
mutation,
|
|
query,
|
|
} from './_generated/server';
|
|
import { getRequiredUserId, normalizeDotfilePath } from './model';
|
|
|
|
const fileMeta = (file: Doc<'userDotfiles'>) => ({
|
|
_id: file._id,
|
|
path: file.path,
|
|
size: file.size,
|
|
isExecutable: file.isExecutable ?? false,
|
|
updatedAt: file.updatedAt,
|
|
});
|
|
|
|
/** Lists the user's dotfile tree (metadata only; content is fetched per-file). */
|
|
export const listMine = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const files = await ctx.db
|
|
.query('userDotfiles')
|
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
|
.collect();
|
|
return files.map(fileMeta).sort((a, b) => a.path.localeCompare(b.path));
|
|
},
|
|
});
|
|
|
|
export const remove = mutation({
|
|
args: { fileId: v.id('userDotfiles') },
|
|
handler: async (ctx, { fileId }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const file = await ctx.db.get(fileId);
|
|
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
|
await ctx.db.delete(fileId);
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
/** Removes every file under a directory prefix (e.g. deleting ".config/nvim"). */
|
|
export const removeDirectory = mutation({
|
|
args: { prefix: v.string() },
|
|
handler: async (ctx, { prefix }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const normalized = normalizeDotfilePath(prefix);
|
|
const files = await ctx.db
|
|
.query('userDotfiles')
|
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
|
.collect();
|
|
const matches = files.filter(
|
|
(f) => f.path === normalized || f.path.startsWith(`${normalized}/`),
|
|
);
|
|
await Promise.all(matches.map((f) => ctx.db.delete(f._id)));
|
|
return { removed: matches.length };
|
|
},
|
|
});
|
|
|
|
export const rename = mutation({
|
|
args: { fileId: v.id('userDotfiles'), path: v.string() },
|
|
handler: async (ctx, { fileId, path }) => {
|
|
const ownerId = await getRequiredUserId(ctx);
|
|
const file = await ctx.db.get(fileId);
|
|
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
|
const normalized = normalizeDotfilePath(path);
|
|
const clash = await ctx.db
|
|
.query('userDotfiles')
|
|
.withIndex('by_owner_path', (q) =>
|
|
q.eq('ownerId', ownerId).eq('path', normalized),
|
|
)
|
|
.unique();
|
|
if (clash && clash._id !== fileId) {
|
|
throw new ConvexError(`A dotfile already exists at ${normalized}.`);
|
|
}
|
|
await ctx.db.patch(fileId, { path: normalized, updatedAt: Date.now() });
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
// Read by the decrypting Node action (userDotfilesNode.getFileContent).
|
|
export const getRawFileInternal = internalQuery({
|
|
args: { fileId: v.id('userDotfiles') },
|
|
handler: async (ctx, { fileId }) => await ctx.db.get(fileId),
|
|
});
|
|
|
|
// Called by the encrypting Node action (userDotfilesNode). Upserts one file by
|
|
// (owner, path).
|
|
export const upsertFileInternal = internalMutation({
|
|
args: {
|
|
ownerId: v.id('users'),
|
|
path: v.string(),
|
|
encryptedContent: v.string(),
|
|
size: v.number(),
|
|
isExecutable: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const existing = await ctx.db
|
|
.query('userDotfiles')
|
|
.withIndex('by_owner_path', (q) =>
|
|
q.eq('ownerId', args.ownerId).eq('path', args.path),
|
|
)
|
|
.unique();
|
|
const now = Date.now();
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
encryptedContent: args.encryptedContent,
|
|
size: args.size,
|
|
isExecutable: args.isExecutable,
|
|
updatedAt: now,
|
|
});
|
|
return existing._id;
|
|
}
|
|
return await ctx.db.insert('userDotfiles', {
|
|
...args,
|
|
updatedAt: now,
|
|
});
|
|
},
|
|
});
|