create-feltkit 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Randy Kendel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,14 @@
1
- # Temporary Holding Version
1
+ # create-feltkit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```bash
4
+ npm create feltkit@latest my-app
5
+ cd my-app
6
+ npm install
7
+ npm run dev
8
+ ```
9
+
10
+ Creates a FeltKit application: a small task manager with projects, tasks, notes
11
+ (AppPort Services Files), three roles, live queries, AppPort capabilities, and tests.
12
+ Refuses to write into a non-empty directory. Has no dependencies.
13
+
14
+ Documentation: https://github.com/rkendel1/concave#readme
package/bin/create.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // npm create feltkit@latest <dir>
3
+ import { resolve } from "node:path";
4
+ import { scaffold, VERSION } from "../scaffold.js";
5
+
6
+ const args = process.argv.slice(2);
7
+ const color = process.stdout.isTTY && !process.env.NO_COLOR;
8
+ const bold = (s) => (color ? `\u001b[1m${s}\u001b[22m` : s);
9
+ const dim = (s) => (color ? `\u001b[2m${s}\u001b[22m` : s);
10
+ const green = (s) => (color ? `\u001b[32m${s}\u001b[39m` : s);
11
+ const red = (s) => (color ? `\u001b[31m${s}\u001b[39m` : s);
12
+
13
+ if (args.includes("--help") || args.includes("-h")) {
14
+ console.log(`npm create feltkit@latest <directory> [-- --name <app-name>]\n\nCreates a FeltKit application (a small task manager you can run immediately).`);
15
+ process.exit(0);
16
+ }
17
+ if (args.includes("--version")) {
18
+ console.log(VERSION);
19
+ process.exit(0);
20
+ }
21
+ const nameIndex = args.indexOf("--name");
22
+ const name = nameIndex >= 0 ? args[nameIndex + 1] : undefined;
23
+ const dir = args.find((a, i) => !a.startsWith("-") && (nameIndex < 0 || i !== nameIndex + 1)) ?? "my-feltkit-app";
24
+
25
+ try {
26
+ const result = scaffold({ target: resolve(process.cwd(), dir), ...(name ? { name } : {}) });
27
+ console.log(`${green("✓")} Created ${bold(result.name)} in ${result.dir}\n`);
28
+ console.log(` cd ${dir}`);
29
+ console.log(" npm install");
30
+ console.log(" npm run dev\n");
31
+ console.log(dim("The dev server prints your app URL and a dashboard link. State lives in .feltkit/development/state."));
32
+ } catch (error) {
33
+ console.error(`${red("✗")} ${error.message}`);
34
+ if (error.hint) console.error(` hint: ${error.hint}`);
35
+ process.exit(1);
36
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "create-feltkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Create a FeltKit application: npm create feltkit@latest my-app",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rkendel1/concave.git",
10
+ "directory": "packages/create"
11
+ },
12
+ "engines": {
13
+ "node": ">=20.11"
14
+ },
15
+ "bin": {
16
+ "create-feltkit": "bin/create.js"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./scaffold.d.ts",
21
+ "import": "./scaffold.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "scaffold.js",
27
+ "scaffold.d.ts",
28
+ "template",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "node sync-template.mjs",
33
+ "prepack": "node sync-template.mjs",
34
+ "test": "node --test test/*.test.mjs"
35
+ }
36
+ }
package/scaffold.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export interface ScaffoldOptions {
2
+ /** Directory to create. Must not exist or be empty. */
3
+ target: string;
4
+ /** Application name; defaults to the directory name. */
5
+ name?: string;
6
+ /** Version spec for feltkit; defaults to this package's version (released in lockstep). */
7
+ sdkSpec?: string;
8
+ }
9
+ export interface ScaffoldResult {
10
+ dir: string;
11
+ name: string;
12
+ sdkSpec: string;
13
+ }
14
+ export class ScaffoldError extends Error {
15
+ readonly code: "TARGET_NOT_EMPTY" | "TEMPLATE_MISSING" | "TEMPLATE_OUT_OF_SYNC";
16
+ readonly hint?: string;
17
+ }
18
+ export declare const VERSION: string;
19
+ export declare function toAppName(input: string): string;
20
+ export declare function templateDir(): string;
21
+ export declare function scaffold(options: ScaffoldOptions): ScaffoldResult;
package/scaffold.js ADDED
@@ -0,0 +1,62 @@
1
+ // Scaffold a FeltKit application from the starter template. No dependencies, so `npm create` is fast.
2
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
7
+
8
+ export class ScaffoldError extends Error {
9
+ constructor(code, message, hint) {
10
+ super(message);
11
+ this.name = "ScaffoldError";
12
+ this.code = code;
13
+ this.hint = hint;
14
+ }
15
+ }
16
+
17
+ export function templateDir() {
18
+ const dir = fileURLToPath(new URL("./template/", import.meta.url));
19
+ if (!existsSync(join(dir, "package.json"))) {
20
+ throw new ScaffoldError("TEMPLATE_MISSING", "The starter template is missing from this installation of create-feltkit.", "Reinstall it: npm create feltkit@latest");
21
+ }
22
+ return dir;
23
+ }
24
+
25
+ export function toAppName(input) {
26
+ const name = String(input)
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9-]+/g, "-")
29
+ .replace(/^[^a-z]+/, "")
30
+ .replace(/-+/g, "-")
31
+ .replace(/-$/, "")
32
+ .slice(0, 63);
33
+ return name || "my-app";
34
+ }
35
+
36
+ export function scaffold({ target, name, sdkSpec }) {
37
+ if (existsSync(target) && readdirSync(target).filter((f) => f !== ".git" && f !== ".DS_Store").length > 0) {
38
+ throw new ScaffoldError("TARGET_NOT_EMPTY", `${target} already exists and is not empty. FeltKit will not write over existing files.`, "Choose a new directory name.");
39
+ }
40
+ const appName = toAppName(name ?? basename(target));
41
+ const spec = sdkSpec ?? process.env.FELTKIT_SDK_SPEC ?? `^${VERSION}`;
42
+ const source = templateDir();
43
+ mkdirSync(target, { recursive: true });
44
+ cpSync(source, target, { recursive: true });
45
+ if (existsSync(join(target, "_gitignore"))) renameSync(join(target, "_gitignore"), join(target, ".gitignore"));
46
+
47
+ const pkgPath = join(target, "package.json");
48
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
49
+ pkg.name = appName;
50
+ pkg.private = true;
51
+ pkg.version = "0.1.0";
52
+ pkg.dependencies["feltkit"] = spec;
53
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
54
+
55
+ const appPath = join(target, "feltkit", "app.ts");
56
+ const app = readFileSync(appPath, "utf8");
57
+ if (!app.includes('name: "tasks"')) {
58
+ throw new ScaffoldError("TEMPLATE_OUT_OF_SYNC", "The starter's feltkit/app.ts no longer declares its name; the template is out of sync.");
59
+ }
60
+ writeFileSync(appPath, app.replace('name: "tasks"', `name: ${JSON.stringify(appName)}`));
61
+ return { dir: target, name: appName, sdkSpec: spec };
62
+ }
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ dist/
3
+ .feltkit/
@@ -0,0 +1,9 @@
1
+ /* Generated by `feltkit dev` / `feltkit codegen`. Do not edit; it is rewritten when feltkit/ changes. */
2
+ /* eslint-disable */
3
+ import { createApi } from "feltkit/client";
4
+ import type * as app_module from "../app.js";
5
+ import type * as projects_module from "../projects.js";
6
+ import type * as tasks_module from "../tasks.js";
7
+
8
+ /** Typed references to every query and mutation. Type-only imports: no backend code reaches the browser. */
9
+ export const api = createApi<{ "app": typeof app_module; "projects": typeof projects_module; "tasks": typeof tasks_module }>();
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The application: its name, who may do what, and its data.
3
+ *
4
+ * Everything here is stored in one FeltDB instance per environment. In development
5
+ * that is .feltkit/development/state — run `npx feltkit inspect` to see it.
6
+ */
7
+ import { feltkit, s } from "feltkit";
8
+
9
+ export const app = feltkit({
10
+ name: "tasks",
11
+ description: "A small task manager that demonstrates the whole FeltKit platform.",
12
+ // Roles grant AppPort permissions. "tasks.*" grants every tasks.<x> permission.
13
+ roles: {
14
+ member: ["projects.*", "tasks.*", "files.*"],
15
+ viewer: ["projects.read", "tasks.read", "files.read"],
16
+ // Agents can read, complete tasks, and leave notes. They cannot create or delete.
17
+ agent: ["projects.read", "tasks.read", "tasks.complete", "tasks.note", "files.read", "files.write"],
18
+ },
19
+ // Development-only identities. Sign in as them from the app or the dashboard.
20
+ dev: {
21
+ users: [
22
+ { id: "ada", name: "Ada Lovelace", roles: ["member"] },
23
+ { id: "grace", name: "Grace Hopper", roles: ["viewer"] },
24
+ ],
25
+ },
26
+ // AppPort Services: Files, stored in this app's FeltDB instance.
27
+ services: { files: true },
28
+ });
29
+
30
+ export const projects = app.collection("projects", {
31
+ name: s.string({ minLength: 1, maxLength: 80 }),
32
+ createdAt: s.number(),
33
+ createdBy: s.string(),
34
+ });
35
+
36
+ export const tasks = app.collection("tasks", {
37
+ projectId: s.string(),
38
+ title: s.string({ minLength: 1, maxLength: 200 }),
39
+ details: s.optional(s.string({ maxLength: 4000 })),
40
+ done: s.boolean(),
41
+ createdAt: s.number(),
42
+ createdBy: s.string(),
43
+ completedAt: s.optional(s.number()),
44
+ completedBy: s.optional(s.string()),
45
+ });
@@ -0,0 +1,56 @@
1
+ import { fail, s } from "feltkit";
2
+ import { app, projects, tasks } from "./app.js";
3
+
4
+ const Project = s.object({
5
+ id: s.string(),
6
+ name: s.string(),
7
+ createdAt: s.number(),
8
+ createdBy: s.string(),
9
+ open: s.integer(),
10
+ done: s.integer(),
11
+ });
12
+
13
+ /** Every project with its task counts. */
14
+ export const list = app.query({
15
+ access: "projects.read",
16
+ returns: s.array(Project),
17
+ capability: { name: "projects.list", description: "List projects with their open and completed task counts." },
18
+ handler: async (ctx) => {
19
+ const [all, allTasks] = await Promise.all([ctx.db.find(projects, {}, { orderBy: [{ field: "createdAt", direction: "asc" }] }), ctx.db.find(tasks)]);
20
+ return all.map((p) => {
21
+ const mine = allTasks.filter((t) => t.projectId === p.id);
22
+ return { id: p.id, name: p.name, createdAt: p.createdAt, createdBy: p.createdBy, open: mine.filter((t) => !t.done).length, done: mine.filter((t) => t.done).length };
23
+ });
24
+ },
25
+ });
26
+
27
+ export const create = app.mutation({
28
+ access: "projects.write",
29
+ args: { name: s.string({ minLength: 1, maxLength: 80 }) },
30
+ returns: s.string(),
31
+ handler: async (ctx, { name }) => {
32
+ const trimmed = name.trim();
33
+ if (!trimmed) fail("INVALID_ARGUMENT", "A project needs a name.");
34
+ const existing = await ctx.db.find(projects, { name: trimmed });
35
+ if (existing.length) fail("CONFLICT", `There is already a project called "${trimmed}".`);
36
+ return ctx.db.insert(projects, { name: trimmed, createdAt: Date.now(), createdBy: ctx.auth.requirePrincipal().id });
37
+ },
38
+ });
39
+
40
+ export const rename = app.mutation({
41
+ access: "projects.write",
42
+ args: { id: s.string(), name: s.string({ minLength: 1, maxLength: 80 }) },
43
+ handler: async (ctx, { id, name }) => {
44
+ await ctx.db.patch(projects, id, { name: name.trim() });
45
+ },
46
+ });
47
+
48
+ /** Deletes a project and all of its tasks in one atomic commit. */
49
+ export const remove = app.mutation({
50
+ access: "projects.write",
51
+ args: { id: s.string() },
52
+ handler: async (ctx, { id }) => {
53
+ for (const task of await ctx.db.find(tasks, { projectId: id })) await ctx.db.delete(tasks, task.id);
54
+ await ctx.db.delete(projects, id);
55
+ },
56
+ });
@@ -0,0 +1,101 @@
1
+ import { fail, s } from "feltkit";
2
+ import { app, projects, tasks } from "./app.js";
3
+
4
+ const Task = s.object({
5
+ id: s.string(),
6
+ projectId: s.string(),
7
+ title: s.string(),
8
+ details: s.optional(s.string()),
9
+ done: s.boolean(),
10
+ createdAt: s.number(),
11
+ createdBy: s.string(),
12
+ completedAt: s.optional(s.number()),
13
+ completedBy: s.optional(s.string()),
14
+ __version: s.number(),
15
+ });
16
+
17
+ /** Tasks in a project: open first, then completed, oldest first. */
18
+ export const list = app.query({
19
+ access: "tasks.read",
20
+ args: { projectId: s.string() },
21
+ returns: s.array(Task),
22
+ capability: { name: "tasks.list", description: "List the tasks in a project, open ones first." },
23
+ handler: async (ctx, { projectId }) => {
24
+ const items = await ctx.db.find(tasks, { projectId }, { orderBy: [{ field: "createdAt", direction: "asc" }] });
25
+ return [...items.filter((t) => !t.done), ...items.filter((t) => t.done)];
26
+ },
27
+ });
28
+
29
+ export const create = app.mutation({
30
+ access: "tasks.write",
31
+ args: { projectId: s.string(), title: s.string({ minLength: 1, maxLength: 200 }), details: s.optional(s.string({ maxLength: 4000 })) },
32
+ returns: s.string(),
33
+ capability: { name: "tasks.create", description: "Add a task to a project." },
34
+ handler: async (ctx, { projectId, title, details }) => {
35
+ if (!(await ctx.db.get(projects, projectId))) fail("NOT_FOUND", "That project does not exist.");
36
+ return ctx.db.insert(tasks, {
37
+ projectId,
38
+ title: title.trim(),
39
+ ...(details?.trim() ? { details: details.trim() } : {}),
40
+ done: false,
41
+ createdAt: Date.now(),
42
+ createdBy: ctx.auth.requirePrincipal().id,
43
+ });
44
+ },
45
+ });
46
+
47
+ export const update = app.mutation({
48
+ access: "tasks.write",
49
+ args: { id: s.string(), title: s.optional(s.string({ minLength: 1, maxLength: 200 })), details: s.optional(s.string({ maxLength: 4000 })) },
50
+ handler: async (ctx, { id, title, details }) => {
51
+ await ctx.db.patch(tasks, id, {
52
+ ...(title !== undefined ? { title: title.trim() } : {}),
53
+ ...(details !== undefined ? { details: details.trim() || undefined } : {}),
54
+ });
55
+ },
56
+ });
57
+
58
+ export const setDone = app.mutation({
59
+ access: "tasks.complete",
60
+ args: { id: s.string(), done: s.boolean() },
61
+ returns: s.object({ id: s.string(), done: s.boolean() }),
62
+ capability: { name: "tasks.complete", description: "Mark a task complete (done: true) or reopen it (done: false)." },
63
+ handler: async (ctx, { id, done }) => {
64
+ const who = ctx.auth.requirePrincipal().id;
65
+ const task = await ctx.db.patch(tasks, id, done ? { done: true, completedAt: Date.now(), completedBy: who } : { done: false, completedAt: undefined, completedBy: undefined });
66
+ return { id: task.id, done: task.done };
67
+ },
68
+ });
69
+
70
+ export const remove = app.mutation({
71
+ access: "tasks.write",
72
+ args: { id: s.string() },
73
+ handler: async (ctx, { id }) => {
74
+ for (const file of await ctx.services.files.list({ metadata: { taskId: id } })) await ctx.services.files.remove(file.id);
75
+ await ctx.db.delete(tasks, id);
76
+ },
77
+ });
78
+
79
+ /** Notes are text files stored by the AppPort Services Files service in this app's FeltDB instance. */
80
+ export const notes = app.query({
81
+ access: "tasks.read",
82
+ args: { taskId: s.string() },
83
+ handler: async (ctx, { taskId }) => {
84
+ const files = await ctx.services.files.list({ metadata: { taskId } });
85
+ return Promise.all(
86
+ files.map(async (file) => ({ id: file.id, author: file.owner, createdAt: file.createdAt, text: (await ctx.services.files.read(file.id)).text() })),
87
+ );
88
+ },
89
+ });
90
+
91
+ export const addNote = app.mutation({
92
+ access: "tasks.note",
93
+ args: { taskId: s.string(), text: s.string({ minLength: 1, maxLength: 8000 }) },
94
+ returns: s.object({ id: s.string() }),
95
+ capability: { name: "tasks.note", description: "Attach a text note to a task (stored with AppPort Services Files)." },
96
+ handler: async (ctx, { taskId, text }) => {
97
+ if (!(await ctx.db.get(tasks, taskId))) fail("NOT_FOUND", "That task does not exist.");
98
+ const file = await ctx.services.files.upload({ name: "note.txt", contentType: "text/plain", content: text.trim(), metadata: { taskId } });
99
+ return { id: file.id };
100
+ },
101
+ });
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
8
+ <title>Tasks · FeltKit</title>
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.tsx"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "feltkit-example-tasks",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "feltkit dev",
8
+ "build": "feltkit build",
9
+ "start": "feltkit start",
10
+ "test": "feltkit test",
11
+ "inspect": "feltkit inspect",
12
+ "deploy": "feltkit deploy",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "feltkit": "0.1.0",
17
+ "react": "19.2.0",
18
+ "react-dom": "19.2.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "22.19.1",
22
+ "@types/react": "19.2.2",
23
+ "@types/react-dom": "19.2.2",
24
+ "@vitejs/plugin-react": "5.1.0",
25
+ "typescript": "5.9.3",
26
+ "vite": "7.3.7"
27
+ }
28
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#14532d"/><path d="M11 7c-4 5-4 13 0 18M21 7c4 5 4 13 0 18" stroke="#ecfdf5" stroke-width="3" fill="none" stroke-linecap="round"/></svg>
@@ -0,0 +1,375 @@
1
+ import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
2
+ import { useConnectionState, useMutation, useQueryState, useSession, type FeltKitClientError } from "feltkit/react";
3
+ import { api } from "../feltkit/_generated/api";
4
+
5
+ type Notice = { tone: "error" | "info"; text: string } | null;
6
+
7
+ /** Same matching rule as AppPort: exact, "ns.*", or "*". */
8
+ function can(granted: string[], permission: string): boolean {
9
+ return granted.some((g) => g === "*" || g === permission || (g.endsWith(".*") && permission.startsWith(g.slice(0, -1))));
10
+ }
11
+
12
+ function describe(error: unknown): string {
13
+ const e = error as FeltKitClientError;
14
+ if (e?.code === "FORBIDDEN") return `Not allowed: ${e.message}. Switch to a member to make changes.`;
15
+ if (e?.code === "UNAUTHORIZED") return "Sign in to continue.";
16
+ if (e?.code === "CONFLICT") return e.message;
17
+ return e?.message ?? String(error);
18
+ }
19
+
20
+ export function App() {
21
+ const { session, signInAsDevUser, signOut } = useSession();
22
+ const live = useConnectionState();
23
+ const [notice, setNotice] = useState<Notice>(null);
24
+ const principal = session?.principal ?? null;
25
+ // UI hint only. The server enforces access on every call regardless of what the UI shows.
26
+ const canWrite = can(session?.permissions ?? [], "tasks.write");
27
+ const canComplete = can(session?.permissions ?? [], "tasks.complete");
28
+
29
+ // In development, sign in as the first dev user so the app works on first load.
30
+ const tried = useRef(false);
31
+ useEffect(() => {
32
+ if (session && !principal && session.devUsers.length && !tried.current) {
33
+ tried.current = true;
34
+ void signInAsDevUser(session.devUsers[0]!.id);
35
+ }
36
+ }, [session, principal, signInAsDevUser]);
37
+
38
+ useEffect(() => {
39
+ if (!notice) return;
40
+ const timer = setTimeout(() => setNotice(null), 6000);
41
+ return () => clearTimeout(timer);
42
+ }, [notice]);
43
+
44
+ const report = (error: unknown) => setNotice({ tone: "error", text: describe(error) });
45
+
46
+ return (
47
+ <div className="shell">
48
+ <header className="topbar">
49
+ <div className="brand">
50
+ <span className="mark" aria-hidden>
51
+ <svg viewBox="0 0 32 32" width="22" height="22">
52
+ <path d="M11 7c-4 5-4 13 0 18M21 7c4 5 4 13 0 18" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" />
53
+ </svg>
54
+ </span>
55
+ <span className="brand-name">Tasks</span>
56
+ {session && <span className={`env ${session.environment.production ? "env-prod" : ""}`}>{session.environment.name}</span>}
57
+ </div>
58
+ <div className="topbar-right">
59
+ <span className={`live ${live ? "on" : ""}`} title={live ? "Live: changes from anyone appear here immediately" : "Connecting to the change stream…"}>
60
+ <span className="dot" /> {live ? "Live" : "Connecting"}
61
+ </span>
62
+ {session && session.devUsers.length > 0 && (
63
+ <label className="identity">
64
+ <span className="sr-only">Signed in as</span>
65
+ <select
66
+ value={principal?.id ?? ""}
67
+ onChange={(e) => (e.target.value ? signInAsDevUser(e.target.value).catch(report) : signOut())}
68
+ aria-label="Signed in as"
69
+ >
70
+ <option value="">Signed out</option>
71
+ {session.devUsers.map((u) => (
72
+ <option key={u.id} value={u.id}>
73
+ {u.name} · {u.roles.join(", ")}
74
+ </option>
75
+ ))}
76
+ </select>
77
+ </label>
78
+ )}
79
+ {session?.dashboard && (
80
+ <a className="dashboard-link" href={session.dashboard} target="_blank" rel="noreferrer">
81
+ Dashboard ↗
82
+ </a>
83
+ )}
84
+ </div>
85
+ </header>
86
+
87
+ {notice && (
88
+ <div className={`notice ${notice.tone}`} role="status">
89
+ {notice.text}
90
+ <button className="ghost" onClick={() => setNotice(null)} aria-label="Dismiss">
91
+ ×
92
+ </button>
93
+ </div>
94
+ )}
95
+
96
+ {!session ? (
97
+ <div className="center muted">Connecting…</div>
98
+ ) : !principal ? (
99
+ <SignedOut devUsers={session.devUsers} onSignIn={(id) => signInAsDevUser(id).catch(report)} />
100
+ ) : (
101
+ <Workspace canWrite={canWrite} canComplete={canComplete} role={principal.roles.join(", ")} onError={report} />
102
+ )}
103
+
104
+ <footer className="footer">
105
+ Every project, task, and note lives in this app’s single FeltDB instance. Changes made by other people, agents (through AppPort), and services appear here live.
106
+ </footer>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function SignedOut({ devUsers, onSignIn }: { devUsers: Array<{ id: string; name: string; roles: string[] }>; onSignIn: (id: string) => void }) {
112
+ return (
113
+ <div className="center">
114
+ <div className="card narrow">
115
+ <h1>Sign in</h1>
116
+ {devUsers.length ? (
117
+ <>
118
+ <p className="muted">Development identities from feltkit/app.ts:</p>
119
+ <div className="stack">
120
+ {devUsers.map((u) => (
121
+ <button key={u.id} className="secondary wide" onClick={() => onSignIn(u.id)}>
122
+ {u.name} <span className="muted">· {u.roles.join(", ")}</span>
123
+ </button>
124
+ ))}
125
+ </div>
126
+ </>
127
+ ) : (
128
+ <p className="muted">This environment has no development sign-in. Configure auth.authenticate in feltkit/app.ts.</p>
129
+ )}
130
+ </div>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function Workspace({ canWrite, canComplete, role, onError }: { canWrite: boolean; canComplete: boolean; role: string; onError: (e: unknown) => void }) {
136
+ const projects = useQueryState(api.projects.list);
137
+ const [selected, setSelected] = useState<string | null>(null);
138
+ const createProject = useMutation(api.projects.create);
139
+ const [name, setName] = useState("");
140
+
141
+ const list = projects.data ?? [];
142
+ const current = useMemo(() => list.find((p) => p.id === selected) ?? list[0] ?? null, [list, selected]);
143
+
144
+ const submit = async (event: FormEvent) => {
145
+ event.preventDefault();
146
+ if (!name.trim()) return;
147
+ try {
148
+ const id = await createProject({ name });
149
+ setName("");
150
+ setSelected(id);
151
+ } catch (error) {
152
+ onError(error);
153
+ }
154
+ };
155
+
156
+ if (projects.isLoading && !projects.data) return <div className="center muted">Loading projects…</div>;
157
+ if (projects.error && !projects.data) return <div className="center"><div className="card narrow error-text">{describe(projects.error)}</div></div>;
158
+
159
+ return (
160
+ <div className="workspace">
161
+ <aside className="sidebar" aria-label="Projects">
162
+ <div className="sidebar-title">Projects</div>
163
+ <nav>
164
+ {list.map((p) => (
165
+ <button key={p.id} className={`project ${current?.id === p.id ? "active" : ""}`} onClick={() => setSelected(p.id)}>
166
+ <span className="project-name">{p.name}</span>
167
+ <span className="count" title={`${p.open} open, ${p.done} done`}>
168
+ {p.open}
169
+ </span>
170
+ </button>
171
+ ))}
172
+ </nav>
173
+ {canWrite && (
174
+ <form onSubmit={submit} className="new-project">
175
+ <input value={name} onChange={(e) => setName(e.target.value)} placeholder="New project" aria-label="New project name" maxLength={80} />
176
+ </form>
177
+ )}
178
+ {!canWrite && <p className="hint">You’re signed in as a {role}. Viewers can read everything but change nothing.</p>}
179
+ </aside>
180
+ <main className="main">
181
+ {current ? (
182
+ <ProjectView key={current.id} project={current} canWrite={canWrite} canComplete={canComplete} onError={onError} />
183
+ ) : (
184
+ <div className="empty">
185
+ <h1>Start your first project</h1>
186
+ <p className="muted">Projects hold tasks. Name one in the sidebar{canWrite ? "" : " (sign in as a member)"} to begin.</p>
187
+ {canWrite && (
188
+ <form onSubmit={submit} className="inline-form">
189
+ <input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Launch plan" aria-label="Project name" />
190
+ <button className="primary" type="submit" disabled={!name.trim()}>
191
+ Create project
192
+ </button>
193
+ </form>
194
+ )}
195
+ </div>
196
+ )}
197
+ </main>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ type Project = { id: string; name: string; open: number; done: number };
203
+
204
+ function ProjectView({ project, canWrite, canComplete, onError }: { project: Project; canWrite: boolean; canComplete: boolean; onError: (e: unknown) => void }) {
205
+ const tasks = useQueryState(api.tasks.list, { projectId: project.id });
206
+ const create = useMutation(api.tasks.create);
207
+ const [title, setTitle] = useState("");
208
+ const [busy, setBusy] = useState(false);
209
+ const [openTask, setOpenTask] = useState<string | null>(null);
210
+
211
+ const add = async (event: FormEvent) => {
212
+ event.preventDefault();
213
+ if (!title.trim()) return;
214
+ setBusy(true);
215
+ try {
216
+ await create({ projectId: project.id, title });
217
+ setTitle("");
218
+ } catch (error) {
219
+ onError(error);
220
+ } finally {
221
+ setBusy(false);
222
+ }
223
+ };
224
+
225
+ const items = tasks.data ?? [];
226
+ const open = items.filter((t) => !t.done).length;
227
+ return (
228
+ <section aria-label={project.name}>
229
+ <div className="project-header">
230
+ <h1>{project.name}</h1>
231
+ <span className="muted">
232
+ {open} open · {items.length - open} done
233
+ </span>
234
+ </div>
235
+ {canWrite && (
236
+ <form onSubmit={add} className="add-task">
237
+ <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Add a task and press Enter" aria-label="New task" maxLength={200} disabled={busy} />
238
+ </form>
239
+ )}
240
+ {tasks.isLoading && !tasks.data ? (
241
+ <p className="muted pad">Loading tasks…</p>
242
+ ) : items.length === 0 ? (
243
+ <div className="empty small">
244
+ <p>No tasks yet.</p>
245
+ <p className="muted">{canWrite ? "Add one above." : "A member can add tasks."}</p>
246
+ </div>
247
+ ) : (
248
+ <ul className="tasks">
249
+ {items.map((task) => (
250
+ <TaskRow key={task.id} task={task} canWrite={canWrite} canComplete={canComplete} open={openTask === task.id} onToggleOpen={() => setOpenTask(openTask === task.id ? null : task.id)} onError={onError} />
251
+ ))}
252
+ </ul>
253
+ )}
254
+ </section>
255
+ );
256
+ }
257
+
258
+ type Task = { id: string; title: string; details?: string; done: boolean; createdBy: string; completedBy?: string };
259
+
260
+ function TaskRow({ task, canWrite, canComplete, open, onToggleOpen, onError }: { task: Task; canWrite: boolean; canComplete: boolean; open: boolean; onToggleOpen: () => void; onError: (e: unknown) => void }) {
261
+ const setDone = useMutation(api.tasks.setDone);
262
+ const update = useMutation(api.tasks.update);
263
+ const remove = useMutation(api.tasks.remove);
264
+ const [editing, setEditing] = useState(false);
265
+ const [draft, setDraft] = useState(task.title);
266
+ const [details, setDetails] = useState(task.details ?? "");
267
+
268
+ useEffect(() => setDraft(task.title), [task.title]);
269
+ useEffect(() => setDetails(task.details ?? ""), [task.details]);
270
+
271
+ const save = async () => {
272
+ setEditing(false);
273
+ if (draft.trim() && draft.trim() !== task.title) await update({ id: task.id, title: draft }).catch(onError);
274
+ else setDraft(task.title);
275
+ };
276
+
277
+ return (
278
+ <li className={`task ${task.done ? "done" : ""} ${open ? "open" : ""}`}>
279
+ <div className="task-row">
280
+ <input type="checkbox" checked={task.done} disabled={!canComplete} onChange={(e) => setDone({ id: task.id, done: e.target.checked }).catch(onError)} aria-label={task.done ? "Reopen task" : "Complete task"} />
281
+ {editing ? (
282
+ <input
283
+ className="title-input"
284
+ value={draft}
285
+ autoFocus
286
+ onChange={(e) => setDraft(e.target.value)}
287
+ onBlur={save}
288
+ onKeyDown={(e) => {
289
+ if (e.key === "Enter") void save();
290
+ if (e.key === "Escape") {
291
+ setDraft(task.title);
292
+ setEditing(false);
293
+ }
294
+ }}
295
+ aria-label="Task title"
296
+ />
297
+ ) : (
298
+ <button className="title" onClick={onToggleOpen} onDoubleClick={() => canWrite && setEditing(true)} aria-expanded={open}>
299
+ {task.title}
300
+ </button>
301
+ )}
302
+ <span className="by muted">{task.done && task.completedBy ? `done by ${task.completedBy}` : task.createdBy}</span>
303
+ {canWrite && !editing && (
304
+ <button className="ghost icon" onClick={() => setEditing(true)} aria-label="Rename task" title="Rename">
305
+ ✎
306
+ </button>
307
+ )}
308
+ </div>
309
+ {open && (
310
+ <div className="task-detail">
311
+ <label className="field">
312
+ <span>Details</span>
313
+ <textarea
314
+ value={details}
315
+ onChange={(e) => setDetails(e.target.value)}
316
+ onBlur={() => details !== (task.details ?? "") && update({ id: task.id, details }).catch(onError)}
317
+ placeholder={canWrite ? "Add details…" : "No details"}
318
+ readOnly={!canWrite}
319
+ rows={3}
320
+ maxLength={4000}
321
+ />
322
+ </label>
323
+ <Notes taskId={task.id} canWrite={canWrite} onError={onError} />
324
+ {canWrite && (
325
+ <div className="row-end">
326
+ <button className="danger-link" onClick={() => remove({ id: task.id }).catch(onError)}>
327
+ Delete task
328
+ </button>
329
+ </div>
330
+ )}
331
+ </div>
332
+ )}
333
+ </li>
334
+ );
335
+ }
336
+
337
+ function Notes({ taskId, canWrite, onError }: { taskId: string; canWrite: boolean; onError: (e: unknown) => void }) {
338
+ const notes = useQueryState(api.tasks.notes, { taskId });
339
+ const addNote = useMutation(api.tasks.addNote);
340
+ const [text, setText] = useState("");
341
+ const submit = async (event: FormEvent) => {
342
+ event.preventDefault();
343
+ if (!text.trim()) return;
344
+ try {
345
+ await addNote({ taskId, text });
346
+ setText("");
347
+ } catch (error) {
348
+ onError(error);
349
+ }
350
+ };
351
+ return (
352
+ <div className="notes">
353
+ <div className="notes-title">
354
+ Notes <span className="muted">· stored by AppPort Services Files</span>
355
+ </div>
356
+ {(notes.data ?? []).map((n) => (
357
+ <div className="note" key={n.id}>
358
+ <div className="note-meta muted">
359
+ {n.author} · {new Date(n.createdAt).toLocaleString()}
360
+ </div>
361
+ <div className="note-text">{n.text}</div>
362
+ </div>
363
+ ))}
364
+ {notes.data?.length === 0 && <p className="muted small-text">No notes yet.</p>}
365
+ {canWrite && (
366
+ <form onSubmit={submit} className="note-form">
367
+ <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Leave a note" aria-label="New note" maxLength={8000} />
368
+ <button className="secondary" type="submit" disabled={!text.trim()}>
369
+ Add
370
+ </button>
371
+ </form>
372
+ )}
373
+ </div>
374
+ );
375
+ }
@@ -0,0 +1,17 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { createClient } from "feltkit/client";
4
+ import { FeltKitProvider } from "feltkit/react";
5
+ import { App } from "./App";
6
+ import "./styles.css";
7
+
8
+ // Same origin as the page: `feltkit dev` serves the app, its functions, and AppPort together.
9
+ const client = createClient();
10
+
11
+ createRoot(document.getElementById("root")!).render(
12
+ <StrictMode>
13
+ <FeltKitProvider client={client}>
14
+ <App />
15
+ </FeltKitProvider>
16
+ </StrictMode>,
17
+ );
@@ -0,0 +1,129 @@
1
+ :root {
2
+ --bg: #f7f7f5;
3
+ --surface: #ffffff;
4
+ --ink: #1b1d1c;
5
+ --muted: #6b706d;
6
+ --line: #e4e5e1;
7
+ --accent: #166534;
8
+ --accent-ink: #ffffff;
9
+ --accent-soft: #e8f3ec;
10
+ --danger: #b42318;
11
+ --warn: #92400e;
12
+ --radius: 10px;
13
+ --shadow: 0 1px 2px rgba(16, 24, 20, 0.06), 0 2px 8px rgba(16, 24, 20, 0.04);
14
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
15
+ color: var(--ink);
16
+ background: var(--bg);
17
+ font-size: 15px;
18
+ line-height: 1.5;
19
+ -webkit-font-smoothing: antialiased;
20
+ }
21
+ @media (prefers-color-scheme: dark) {
22
+ :root {
23
+ --bg: #111312;
24
+ --surface: #181b1a;
25
+ --ink: #eceeed;
26
+ --muted: #9aa19d;
27
+ --line: #2a2f2c;
28
+ --accent: #4ade80;
29
+ --accent-ink: #052e16;
30
+ --accent-soft: #15301f;
31
+ --danger: #f97066;
32
+ --warn: #fbbf24;
33
+ --shadow: none;
34
+ }
35
+ }
36
+ * { box-sizing: border-box; }
37
+ body { margin: 0; }
38
+ button, input, select, textarea { font: inherit; color: inherit; }
39
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
40
+ .muted { color: var(--muted); }
41
+ .shell { min-height: 100vh; display: flex; flex-direction: column; }
42
+
43
+ .topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 2; }
44
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; }
45
+ .mark { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--accent); color: var(--accent-ink); }
46
+ .env { font-size: 12px; font-weight: 500; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); }
47
+ .env-prod { background: #fef3c7; color: var(--warn); }
48
+ .topbar-right { display: flex; align-items: center; gap: 14px; }
49
+ .live { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
50
+ .live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line); }
51
+ .live.on .dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); }
52
+ .identity select { padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
53
+ .dashboard-link { font-size: 13px; color: var(--muted); text-decoration: none; }
54
+ .dashboard-link:hover { color: var(--ink); }
55
+
56
+ .notice { margin: 12px 20px 0; padding: 10px 14px; border-radius: var(--radius); display: flex; justify-content: space-between; align-items: center; gap: 12px; }
57
+ .notice.error { background: #fef3f2; color: var(--danger); border: 1px solid #fecdca; }
58
+ .notice.info { background: var(--accent-soft); color: var(--accent); }
59
+ @media (prefers-color-scheme: dark) { .notice.error { background: #2a1513; border-color: #55201b; } }
60
+
61
+ .workspace { flex: 1; display: grid; grid-template-columns: 260px 1fr; min-height: 0; }
62
+ .sidebar { border-right: 1px solid var(--line); padding: 20px 12px; display: flex; flex-direction: column; gap: 6px; }
63
+ .sidebar-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 0 10px 6px; }
64
+ .sidebar nav { display: flex; flex-direction: column; gap: 2px; }
65
+ .project { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 10px; border: 0; background: transparent; border-radius: 8px; cursor: pointer; text-align: left; }
66
+ .project:hover { background: var(--surface); }
67
+ .project.active { background: var(--surface); box-shadow: var(--shadow); font-weight: 600; }
68
+ .project-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
69
+ .count { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
70
+ .new-project input { width: 100%; margin-top: 8px; padding: 8px 10px; border: 1px dashed var(--line); border-radius: 8px; background: transparent; }
71
+ .new-project input:focus { border-style: solid; outline: 2px solid var(--accent-soft); }
72
+ .hint { font-size: 13px; color: var(--muted); padding: 8px 10px; }
73
+
74
+ .main { padding: 28px 36px 60px; max-width: 860px; width: 100%; }
75
+ .project-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
76
+ h1 { font-size: 24px; letter-spacing: -0.01em; margin: 0; }
77
+ .add-task input { width: 100%; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
78
+ .add-task input:focus, .title-input:focus, .note-form input:focus, textarea:focus, .inline-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
79
+
80
+ .tasks { list-style: none; padding: 0; margin: 18px 0 0; display: flex; flex-direction: column; gap: 6px; }
81
+ .task { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
82
+ .task.open { box-shadow: var(--shadow); }
83
+ .task-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; }
84
+ .task-row input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; flex: none; }
85
+ .title { flex: 1; text-align: left; border: 0; background: none; padding: 2px 0; cursor: pointer; }
86
+ .task.done .title { color: var(--muted); text-decoration: line-through; }
87
+ .title-input { flex: 1; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); }
88
+ .by { font-size: 12px; }
89
+ .task-detail { border-top: 1px solid var(--line); padding: 14px 16px 16px 42px; display: flex; flex-direction: column; gap: 14px; }
90
+ .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); }
91
+ textarea { padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); resize: vertical; color: var(--ink); }
92
+ .notes-title { font-size: 13px; font-weight: 600; }
93
+ .note { padding: 8px 10px; border-left: 2px solid var(--accent); background: var(--bg); border-radius: 0 8px 8px 0; margin-top: 6px; }
94
+ .note-meta { font-size: 12px; }
95
+ .note-text { white-space: pre-wrap; }
96
+ .note-form, .inline-form { display: flex; gap: 8px; margin-top: 8px; }
97
+ .note-form input, .inline-form input { flex: 1; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
98
+ .row-end { display: flex; justify-content: flex-end; }
99
+ .small-text { font-size: 13px; margin: 4px 0; }
100
+
101
+ button.primary { background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 8px; padding: 8px 14px; font-weight: 600; cursor: pointer; }
102
+ button.secondary { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; cursor: pointer; }
103
+ button.secondary:hover { border-color: var(--muted); }
104
+ button:disabled { opacity: 0.5; cursor: default; }
105
+ button.ghost { background: none; border: 0; cursor: pointer; color: inherit; font-size: 18px; line-height: 1; }
106
+ button.icon { color: var(--muted); font-size: 14px; opacity: 0; }
107
+ .task-row:hover .icon, .task-row .icon:focus { opacity: 1; }
108
+ .danger-link { background: none; border: 0; color: var(--danger); cursor: pointer; font-size: 13px; }
109
+ .wide { width: 100%; text-align: left; }
110
+ .stack { display: flex; flex-direction: column; gap: 8px; }
111
+
112
+ .empty { margin-top: 12vh; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 4px; }
113
+ .empty.small { margin-top: 36px; }
114
+ .empty p { margin: 0; }
115
+ .center { flex: 1; display: grid; place-items: center; padding: 40px; }
116
+ .card { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 24px; box-shadow: var(--shadow); }
117
+ .narrow { width: min(420px, 100%); }
118
+ .card h1 { margin-bottom: 8px; }
119
+ .error-text { color: var(--danger); }
120
+ .pad { padding: 12px 0; }
121
+ .footer { padding: 14px 20px; border-top: 1px solid var(--line); font-size: 12px; color: var(--muted); text-align: center; }
122
+
123
+ @media (max-width: 720px) {
124
+ .workspace { grid-template-columns: 1fr; }
125
+ .sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
126
+ .main { padding: 20px 16px 48px; }
127
+ .topbar { flex-wrap: wrap; }
128
+ .task-detail { padding-left: 16px; }
129
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Run with `npm test` (`feltkit test`). Each test app gets its own FeltDB instance
3
+ * in a temporary directory — real storage, nothing shared with development.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { createTestApp } from "feltkit/testing";
8
+ import { api } from "../feltkit/_generated/api.js";
9
+
10
+ test("members manage projects and tasks", async () => {
11
+ const t = await createTestApp();
12
+ try {
13
+ const ada = t.as("ada");
14
+ const projectId = await ada.mutation(api.projects.create, { name: "Launch" });
15
+ const taskId = await ada.mutation(api.tasks.create, { projectId, title: "Write the README" });
16
+ await ada.mutation(api.tasks.update, { id: taskId, details: "Cover the first five minutes." });
17
+ await ada.mutation(api.tasks.setDone, { id: taskId, done: true });
18
+
19
+ const [task] = await ada.query(api.tasks.list, { projectId });
20
+ assert.equal(task!.title, "Write the README");
21
+ assert.equal(task!.details, "Cover the first five minutes.");
22
+ assert.equal(task!.done, true);
23
+ assert.equal(task!.completedBy, "ada");
24
+
25
+ const [project] = await ada.query(api.projects.list);
26
+ assert.deepEqual({ open: project!.open, done: project!.done }, { open: 0, done: 1 });
27
+ } finally {
28
+ await t.close();
29
+ }
30
+ });
31
+
32
+ test("viewers can read but not write; anonymous callers can do neither", async () => {
33
+ const t = await createTestApp();
34
+ try {
35
+ const projectId = await t.as("ada").mutation(api.projects.create, { name: "Docs" });
36
+ const grace = t.as("grace");
37
+ assert.equal((await grace.query(api.projects.list)).length, 1);
38
+ await assert.rejects(grace.mutation(api.tasks.create, { projectId, title: "nope" }), { code: "FORBIDDEN" });
39
+ await assert.rejects(t.query(api.projects.list), { code: "UNAUTHORIZED" });
40
+ } finally {
41
+ await t.close();
42
+ }
43
+ });
44
+
45
+ test("notes are stored by the Files service and readable by viewers", async () => {
46
+ const t = await createTestApp();
47
+ try {
48
+ const ada = t.as("ada");
49
+ const projectId = await ada.mutation(api.projects.create, { name: "Research" });
50
+ const taskId = await ada.mutation(api.tasks.create, { projectId, title: "Read the paper" });
51
+ await ada.mutation(api.tasks.addNote, { taskId, text: "Section 3 is the important one." });
52
+ const notes = await t.as("grace").query(api.tasks.notes, { taskId });
53
+ assert.deepEqual(notes.map((n) => [n.author, n.text]), [["ada", "Section 3 is the important one."]]);
54
+ await assert.rejects(t.as("grace").mutation(api.tasks.addNote, { taskId, text: "x" }), { code: "FORBIDDEN" });
55
+ } finally {
56
+ await t.close();
57
+ }
58
+ });
59
+
60
+ test("deleting a project removes its tasks in one atomic commit", async () => {
61
+ const t = await createTestApp();
62
+ try {
63
+ const ada = t.as("ada");
64
+ const projectId = await ada.mutation(api.projects.create, { name: "Temporary" });
65
+ await ada.mutation(api.tasks.create, { projectId, title: "a" });
66
+ await ada.mutation(api.tasks.create, { projectId, title: "b" });
67
+ await ada.mutation(api.projects.remove, { id: projectId });
68
+ assert.deepEqual(await ada.query(api.projects.list), []);
69
+ assert.deepEqual(await ada.query(api.tasks.list, { projectId }), []);
70
+ const entry = (await t.runtime.activity.list({ name: "projects:remove" }))[0]!;
71
+ assert.deepEqual(entry.writes!.map((w) => w.op).sort(), ["delete", "delete", "delete"]);
72
+ } finally {
73
+ await t.close();
74
+ }
75
+ });
76
+
77
+ test("an agent can complete tasks and leave notes, but not create them", async () => {
78
+ const t = await createTestApp();
79
+ try {
80
+ const projectId = await t.as("ada").mutation(api.projects.create, { name: "Ops" });
81
+ const taskId = await t.as("ada").mutation(api.tasks.create, { projectId, title: "Rotate keys" });
82
+ const agent = t.as({ id: "ops-bot", type: "agent", roles: ["agent"] });
83
+ await agent.mutation(api.tasks.setDone, { id: taskId, done: true });
84
+ await agent.mutation(api.tasks.addNote, { taskId, text: "Rotated." });
85
+ await assert.rejects(agent.mutation(api.tasks.create, { projectId, title: "x" }), { code: "FORBIDDEN" });
86
+ } finally {
87
+ await t.close();
88
+ }
89
+ });
90
+
91
+ test("state survives a restart", async () => {
92
+ const t = await createTestApp();
93
+ try {
94
+ const projectId = await t.as("ada").mutation(api.projects.create, { name: "Durable" });
95
+ await t.restart();
96
+ const [project] = await t.as("ada").query(api.projects.list);
97
+ assert.equal(project!.id, projectId);
98
+ } finally {
99
+ await t.close();
100
+ }
101
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "isolatedModules": true,
12
+ "verbatimModuleSyntax": true,
13
+ "types": ["node", "vite/client"]
14
+ },
15
+ "include": ["src", "feltkit", "tests", "vite.config.ts"]
16
+ }
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ });