create-coline-app 2.2.0 → 2.5.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.
Files changed (32) hide show
  1. package/AGENTS.md +86 -0
  2. package/README.md +60 -7
  3. package/assets/preview-host.ts +130 -0
  4. package/bin/_run.mjs +11 -2
  5. package/package.json +9 -4
  6. package/src/check.ts +46 -0
  7. package/src/cli.test.ts +154 -0
  8. package/src/cli.ts +12 -1
  9. package/src/client-build.ts +50 -0
  10. package/src/dev.ts +6 -5
  11. package/src/preview-capture.ts +101 -0
  12. package/src/preview.ts +189 -0
  13. package/src/push.ts +8 -47
  14. package/src/scaffold.ts +6 -0
  15. package/templates/backendless/.agents/skills/coline-app-development/SKILL.md +36 -0
  16. package/templates/backendless/.agents/skills/coline-app-development/references/live-records.md +53 -0
  17. package/templates/backendless/.agents/skills/coline-ui-design/SKILL.md +67 -0
  18. package/templates/backendless/.agents/skills/coline-ui-design/assets/board.png +0 -0
  19. package/templates/backendless/.agents/skills/coline-ui-design/assets/settings.png +0 -0
  20. package/templates/backendless/.agents/skills/coline-ui-design/assets/triage.png +0 -0
  21. package/templates/backendless/.agents/skills/coline-ui-review/SKILL.md +39 -0
  22. package/templates/backendless/AGENTS.md +79 -194
  23. package/templates/backendless/CLAUDE.md +1 -5
  24. package/templates/backendless/README.md +22 -27
  25. package/templates/backendless/app.config.ts +19 -11
  26. package/templates/backendless/app.css +84 -0
  27. package/templates/backendless/examples/board.tsx +173 -0
  28. package/templates/backendless/examples/settings.tsx +117 -0
  29. package/templates/backendless/examples/triage.tsx +253 -0
  30. package/templates/backendless/main.tsx +232 -0
  31. package/templates/backendless/package.json +16 -6
  32. package/templates/backendless/preview.seed.ts +26 -0
package/AGENTS.md ADDED
@@ -0,0 +1,86 @@
1
+ # create-coline-app package agent guide
2
+
3
+ This package owns the external developer experience: the `create-coline-app`
4
+ scaffold and the `coline-app push/dev` CLI. The template is a tested public
5
+ contract, not illustrative pseudocode.
6
+
7
+ ## Source of truth
8
+
9
+ - `src/scaffold.ts` copies `templates/backendless` and replaces app
10
+ placeholders.
11
+ - `src/push.ts` bundles `app.config.ts`, extracts the manifest, and collects
12
+ `main.tsx` plus app `.ts`, `.tsx`, and `.css` source for React surfaces.
13
+ - `templates/backendless/AGENTS.md` is the primary guidance delivered to
14
+ every generated app.
15
+ - `templates/backendless/.agents/skills/` contains the reusable task skill
16
+ discovered by OpenCode/Codex-style agents.
17
+
18
+ ## Non-negotiable scaffold contract
19
+
20
+ After a clean `npx create-coline-app app && cd app && npm install`, the project
21
+ must have a working backend example, a working React starter, accurate agent
22
+ guidance, and tests that do not need the Coline product repository or a running
23
+ Coline server.
24
+
25
+ Keep these versions aligned in one release:
26
+
27
+ - `@colineapp/sdk` — logic, manifest, capabilities, tree UI, test workspace
28
+ - `@colineapp/ui` — React provider, hooks, components, stylesheet
29
+ - `@colineapp/app-runtime` — guest/host bridge; direct scaffold dev dependency for local preview
30
+ - `create-coline-app` — scaffold and push/dev CLI
31
+
32
+ When any public API changes, update the package README, package `AGENTS.md`,
33
+ template `AGENTS.md`, template skill, template dependencies, and the focused
34
+ smoke test together.
35
+
36
+ ## Agent-safety rules
37
+
38
+ - A generated app must be self-describing. Agents should not need to inspect
39
+ `/Users/radin/coline-app`, a worktree, or private first-party apps.
40
+ - Do not make the template point at local workspace packages or private URLs.
41
+ - Do not claim a dependency is “already wired” unless it is in the generated
42
+ `package.json` and exercised by the template.
43
+ - Do not add a React example without the `main.tsx` entry, React peer packages,
44
+ CSS export, and a manifest surface that references it.
45
+ - Keep external hosting, network, auth, and database claims explicit. The
46
+ default app is hosted and backendless.
47
+
48
+ ## Verification
49
+
50
+ - Run `pnpm --filter create-coline-app typecheck` and
51
+ `pnpm --filter create-coline-app test`.
52
+ - The CLI tests must assert placeholder replacement, exact package dependency
53
+ wiring, the React starter's presence, and that the app config bundles against
54
+ the real SDK source.
55
+ - Never use a checked-in private app as the only proof that a public scaffold
56
+ works. Add the smallest self-contained fixture instead.
57
+
58
+ ## Release hygiene
59
+
60
+ The package publishes `bin`, `src`, `assets`, `templates`, `README.md`, `AGENTS.md`, and
61
+ `LICENSE`. Bump the CLI version when the template contract changes. Publish
62
+ the three runtime libraries first, then publish this CLI with matching ranges.
63
+
64
+ ## SDK 0.4 / CLI 2.4 workflow
65
+
66
+ Run `coline-app check` before pushing: it checks types, the manifest, hosted logic,
67
+ and the React bundle without uploading. `dev --internal` updates one development
68
+ version; install it in the workspace and reload after changes. The CLI bundles
69
+ from the app's installed dependencies and preserves imported CSS and inline assets.
70
+ CLI 2.5 compiles app-specific Tailwind v4 and ordinary CSS automatically.
71
+ Import UI styles, then app.css; use complete class names. Local `preview` uses
72
+ the public test workspace and sandbox bridge, requires no credentials, and can
73
+ capture light/dark/mobile/error states with `--screenshots <directory>`.
74
+
75
+ The starter includes a React home and editor, tree mobile fallback, and a tool.
76
+ Use `coline.tools.invoke` or `actions.invokeTool` to run the same declared tool
77
+ from UI that Kairo runs. Save with `expectedVersion` to detect concurrent edits.
78
+ Homes/editors can write within grants; previews and server render handlers cannot.
79
+ `createTestWorkspace(app).client()` tests the interactive contract and
80
+ `grantedPermissions` simulates revoked consent.
81
+
82
+ New `tasks`, `calendar`, `messages`, and `references` capabilities require their
83
+ explicit manifest permissions and retain the invoking user's workspace access.
84
+ Use `files.list({ scope: "workspace" })` for shared files; document writes stay
85
+ with the owning app. Collections are install-private. Code integration and a
86
+ native mobile React runtime are separate work.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-coline-app
2
2
 
3
- Scaffold, push, and iterate on Coline Apps.
3
+ Scaffold, push, and iterate on apps for the Coline App Store.
4
4
 
5
5
  ```sh
6
6
  npx create-coline-app my-app
@@ -9,14 +9,67 @@ cd my-app && npm install && npm test
9
9
 
10
10
  Two binaries ship from this package:
11
11
 
12
- - **`create-coline-app <dir>`** — scaffolds a backendless app: a
13
- `defineApp` config with a Kairo tool, a file type with tree surfaces,
14
- a hosted home surface, and tests against `@colineapp/sdk/testing`.
12
+ - **`create-coline-app <dir>`** — scaffolds a self-contained backendless app:
13
+ a `defineApp` config with a Kairo tool, a file type with tree surfaces, a
14
+ working React home using `@colineapp/ui`, mobile/tree fallback rendering,
15
+ agent guidance, and tests against `@colineapp/sdk/testing`.
15
16
  - **`coline-app push | dev`** — builds the logic bundle with esbuild,
16
17
  extracts the manifest through the two-artifact model (plan §6.2),
17
- collects client source when the manifest declares react surfaces, and
18
+ bundles installed client dependencies and CSS when the manifest declares React surfaces, and
18
19
  uploads to `POST /api/v1/apps/{key}/versions` with a workspace API key
19
20
  (`apps.write` scope). `dev` watches and re-pushes stamped versions.
20
21
 
21
- The CLI never builds tier-2 client bundles itself it ships source and
22
- Coline's reviewed build pipeline produces the bundle.
22
+ The CLI bundles the client locally. Coline validates, rebuilds, and hashes the
23
+ uploaded artifact before serving it in the sandbox.
24
+
25
+ The generated project includes `AGENTS.md` and
26
+ `.agents/skills/coline-app-development/SKILL.md`. Those files are deliberately
27
+ specific to the installed public packages so coding agents can work without
28
+ reading Coline's private product repository.
29
+
30
+ ## SDK 0.4 / CLI 2.5 workflow
31
+
32
+ Run `coline-app check` before pushing: it checks types, the manifest, hosted logic,
33
+ and the React bundle without uploading. `dev --internal` updates one development
34
+ version; install it in the workspace and reload after changes. The CLI bundles
35
+ from the app's installed dependencies and preserves imported CSS and inline assets.
36
+ App-specific Tailwind v4 utilities compile automatically with CLI 2.5. Import
37
+ `@colineapp/ui/styles.css` and then `./app.css`; use complete class names.
38
+
39
+ The starter includes a React home and editor, tree mobile fallback, and a tool.
40
+ Use `coline.tools.invoke` or `actions.invokeTool` to run the same declared tool
41
+ from UI that Kairo runs. Save with `expectedVersion` to detect concurrent edits.
42
+ Homes/editors can write within grants; previews and server render handlers cannot.
43
+ `createTestWorkspace(app).client()` tests the interactive contract and
44
+ `grantedPermissions` simulates revoked consent.
45
+
46
+ New `tasks`, `calendar`, `messages`, and `references` capabilities require their
47
+ explicit manifest permissions and retain the invoking user's workspace access.
48
+ Use `files.list({ scope: "workspace" })` for shared files; document writes stay
49
+ with the owning app. Collections are install-private. Code integration and a
50
+ native mobile React runtime are separate work.
51
+
52
+ ## Agent UI workflow (experimental)
53
+
54
+ Generated apps include original Coline-specific development, design, and review
55
+ skills under `.agents/skills`, plus plain `AGENTS.md` and a `CLAUDE.md` pointer.
56
+ The UI examples cover a request queue/detail panel, editorial board, and settings.
57
+ Their state is local: adapt the layout, then connect the real SDK capabilities.
58
+
59
+ ```sh
60
+ npm run preview # local notes app with seeded data
61
+ npm run preview -- --pattern triage # also board / settings
62
+ npx playwright install chromium # once, for screenshot capture
63
+ npm run screenshots # .coline/screenshots/report.json
64
+ ```
65
+
66
+ Preview binds a random loopback port and uses the published SDK test workspace
67
+ and real sandbox bridge. It does not need credentials or upload anything.
68
+ Theme/state controls and live source reload make iteration cheap. `preview.seed.ts`
69
+ can seed capabilities without importing fixtures into the production entry.
70
+
71
+ The screenshot command captures light/dark × desktop/mobile × five states and
72
+ reports browser errors, page overflow, and unlabelled fields. It exits nonzero
73
+ on those issues. Inspect images and exercise interactions before calling the UI
74
+ finished. This is not a visual-quality score or a hosted authorization, realtime,
75
+ or performance test. Models without image/tool support need human visual review.
@@ -0,0 +1,130 @@
1
+ // Compiled for the local preview host only. It never enters an uploaded app bundle.
2
+ import { createHostBridge } from "@colineapp/app-runtime";
3
+ import { createTestWorkspace } from "@colineapp/sdk/testing";
4
+ import app from "coline-preview-app";
5
+ import { seed } from "coline-preview-seed";
6
+
7
+ const params = new URLSearchParams(location.search);
8
+ const mode = params.get("state") ?? "ready";
9
+ const dark = params.get("theme") === "dark";
10
+ const workspace = createTestWorkspace(app);
11
+ if (mode !== "empty") await seed(workspace);
12
+ const coline = workspace.client();
13
+ const frame = document.querySelector<HTMLIFrameElement>("iframe")!;
14
+ let bridge: ReturnType<typeof createHostBridge> | null = null;
15
+ let fileId: string | null = null;
16
+ const notify = (message: string) => {
17
+ document.querySelector<HTMLOutputElement>("#notice")!.textContent = message;
18
+ };
19
+ async function mount() {
20
+ bridge?.dispose();
21
+ const stored = fileId ? workspace.state.files.get(fileId) : null;
22
+ bridge = createHostBridge({
23
+ frame,
24
+ context: {
25
+ surface: stored ? "editor" : "home",
26
+ workspace: {
27
+ workspaceId: "ws_test",
28
+ workspaceSlug: "test-workspace",
29
+ workspaceName: "Preview workspace",
30
+ },
31
+ app: { appKey: app.manifest.key, installId: "inst_test" },
32
+ actor: { userId: "user_test" },
33
+ file: stored
34
+ ? {
35
+ fileId: stored.fileId,
36
+ name: stored.name,
37
+ typeKey: stored.typeKey,
38
+ version: stored.version,
39
+ }
40
+ : null,
41
+ document: stored?.document ?? null,
42
+ },
43
+ theme: {},
44
+ colorScheme: dark ? "dark" : "light",
45
+ manifestPermissions: app.manifest.permissions,
46
+ grantedPermissions: mode === "denied" ? [] : app.manifest.permissions,
47
+ effectCeiling: "destructive",
48
+ delegate: {
49
+ async callCapability(operation, args) {
50
+ if (mode === "loading") return new Promise(() => undefined);
51
+ if (mode === "error")
52
+ throw Object.assign(
53
+ new Error("Preview: simulate a failed request. Offer a recovery action."),
54
+ { code: "preview_error" },
55
+ );
56
+ const parts = operation.split(".");
57
+ let target: unknown = coline;
58
+ if (operation.startsWith("storage.records.")) {
59
+ target = coline.storage.collection(String(args[0]));
60
+ args = args.slice(1);
61
+ parts.splice(0, 2);
62
+ }
63
+ for (const part of parts.slice(0, -1)) target = (target as Record<string, unknown>)[part];
64
+ const method = (target as Record<string, unknown>)[parts.at(-1)!];
65
+ if (typeof method !== "function")
66
+ throw new Error(`Preview does not implement ${operation}.`);
67
+ return Reflect.apply(method, target, args);
68
+ },
69
+ async showPicker(picker, options) {
70
+ if (picker === "confirm") return confirm(String(options.title ?? "Confirm"));
71
+ const items =
72
+ picker === "file"
73
+ ? [...workspace.state.files.values()].map((file) => ({
74
+ id: file.fileId,
75
+ label: file.name,
76
+ }))
77
+ : [{ id: "user_test", label: "Preview user" }];
78
+ const selected = prompt(
79
+ `${String(options.title ?? `Choose ${picker}`)}\n${items.map((item) => `${item.id}: ${item.label}`).join("\n")}`,
80
+ );
81
+ if (!selected) return null;
82
+ if (!items.some((item) => item.id === selected))
83
+ throw new Error("Select one of the listed IDs.");
84
+ return picker === "file" ? { fileId: selected } : { userId: selected };
85
+ },
86
+ onNavigate(intent, payload) {
87
+ if (intent === "openFile") {
88
+ fileId = String(payload.fileId);
89
+ void mount();
90
+ } else if (intent === "openAppHome") {
91
+ fileId = null;
92
+ void mount();
93
+ } else
94
+ notify(
95
+ `Navigation requested: ${String(payload.href ?? intent)} (not opened from preview).`,
96
+ );
97
+ },
98
+ onResize() {},
99
+ onToast: notify,
100
+ onReady() {
101
+ frame.dataset.ready = "true";
102
+ },
103
+ },
104
+ });
105
+ frame.removeAttribute("data-ready");
106
+ frame.src = `/app?${params}&generation=${Date.now()}`;
107
+ }
108
+ window.addEventListener("message", (event) => bridge?.handleMessage(event));
109
+ workspace.state.realtimeListeners.set(
110
+ "coline.records.changed",
111
+ new Set([(message) => bridge?.pushRealtimeEvent(message.event, message.data)]),
112
+ );
113
+ await mount();
114
+ for (const select of document.querySelectorAll<HTMLSelectElement>("select[data-param]")) {
115
+ select.value = params.get(select.dataset.param!) ?? select.value;
116
+ select.onchange = () => {
117
+ params.set(select.dataset.param!, select.value);
118
+ location.search = params.toString();
119
+ };
120
+ }
121
+ let revision: string | null = null;
122
+ setInterval(() => {
123
+ void fetch("/revision")
124
+ .then((response) => response.text())
125
+ .then((value) => {
126
+ if (revision !== null && value !== revision) location.reload();
127
+ revision = value;
128
+ })
129
+ .catch(() => notify("Preview server stopped."));
130
+ }, 700);
package/bin/_run.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // caches it inside the package, and dispatches to the named entrypoint.
3
3
  import { build } from "esbuild";
4
4
  import { createHash } from "node:crypto";
5
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
 
@@ -10,7 +10,16 @@ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
10
10
 
11
11
  async function ensureCliBundle() {
12
12
  const entry = join(packageRoot, "src", "cli.ts");
13
- const source = await readFile(entry, "utf8");
13
+ const sourceFiles = (await readdir(join(packageRoot, "src")))
14
+ .filter((name) => name.endsWith(".ts"))
15
+ .sort();
16
+ const source = (
17
+ await Promise.all(
18
+ sourceFiles.map(
19
+ async (name) => name + (await readFile(join(packageRoot, "src", name), "utf8")),
20
+ ),
21
+ )
22
+ ).join("\n");
14
23
  const stamp = createHash("sha256").update(source).digest("hex").slice(0, 16);
15
24
  const outFile = join(packageRoot, ".cache", `cli.${stamp}.mjs`);
16
25
  const exists = await stat(outFile).then(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-coline-app",
3
- "version": "2.2.0",
4
- "description": "Scaffold, push, and iterate on Coline Apps.",
3
+ "version": "2.5.0",
4
+ "description": "Scaffold, push, and iterate on apps for the Coline App Store.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-coline-app": "./bin/create-coline-app.mjs",
@@ -12,10 +12,15 @@
12
12
  "src",
13
13
  "templates",
14
14
  "README.md",
15
- "LICENSE"
15
+ "AGENTS.md",
16
+ "LICENSE",
17
+ "assets"
16
18
  ],
17
19
  "dependencies": {
18
- "esbuild": "^0.25.0"
20
+ "esbuild": "^0.25.0",
21
+ "@tailwindcss/postcss": "^4.3.0",
22
+ "postcss": "^8.5.6",
23
+ "playwright": "^1.58.2"
19
24
  },
20
25
  "devDependencies": {
21
26
  "@types/node": "^22.10.2",
package/src/check.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { resolve, join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { argValue } from "./args";
5
+ import { buildLogicBundle, extractManifest } from "./push";
6
+ import { buildClientSource } from "./client-build";
7
+
8
+ /** Check the actual upload artifacts without credentials or a workspace mutation. */
9
+ export async function checkApp(
10
+ dir: string,
11
+ ): Promise<{ appKey: string; logicBytes: number; clientBytes: number }> {
12
+ const packageJson = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) as {
13
+ devDependencies?: Record<string, string>;
14
+ };
15
+ if (packageJson.devDependencies?.typescript) {
16
+ const code = await new Promise<number | null>((resolveExit, reject) => {
17
+ const child = spawn(
18
+ process.execPath,
19
+ [join(dir, "node_modules/typescript/bin/tsc"), "--noEmit"],
20
+ { cwd: dir, stdio: "inherit" },
21
+ );
22
+ child.on("error", reject);
23
+ child.on("exit", resolveExit);
24
+ });
25
+ if (code !== 0) throw new Error("Typecheck failed. Fix the errors above before pushing.");
26
+ }
27
+ const logic = await buildLogicBundle(dir);
28
+ const manifest = await extractManifest(logic);
29
+ const hasReact = [
30
+ ...Object.values(manifest.surfaces ?? {}),
31
+ ...manifest.files.flatMap((file) => Object.values(file.surfaces ?? {})),
32
+ ].some((surface) => surface?.tier === "react");
33
+ const client = hasReact ? await buildClientSource(dir) : null;
34
+ return {
35
+ appKey: manifest.key,
36
+ logicBytes: Buffer.byteLength(logic),
37
+ clientBytes: client ? Buffer.byteLength(client.files[client.entry] ?? "") : 0,
38
+ };
39
+ }
40
+ export async function runCheck(argv: string[]): Promise<number> {
41
+ const result = await checkApp(resolve(argValue(argv, "--dir") ?? "."));
42
+ console.log(
43
+ `${result.appKey}: typecheck and bundles passed (${result.logicBytes} bytes logic, ${result.clientBytes} bytes client). No upload.`,
44
+ );
45
+ return 0;
46
+ }
package/src/cli.test.ts CHANGED
@@ -48,8 +48,32 @@ describe("runCreate", () => {
48
48
  expect(config).not.toContain("__APP_KEY__");
49
49
  const packageJson = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) as {
50
50
  name: string;
51
+ dependencies: Record<string, string>;
52
+ devDependencies: Record<string, string>;
51
53
  };
52
54
  expect(packageJson.name).toBe("team-crm");
55
+ expect(packageJson.dependencies["@colineapp/sdk"]).toBe("^0.4.0");
56
+ expect(packageJson.dependencies["@colineapp/ui"]).toBe("^0.4.0");
57
+ expect(packageJson.dependencies.react).toBe("^19.0.0");
58
+ expect(packageJson.dependencies["react-dom"]).toBe("^19.0.0");
59
+ expect(packageJson.devDependencies["create-coline-app"]).toBe("^2.5.0");
60
+ const main = await readFile(join(dir, "main.tsx"), "utf8");
61
+ expect(main).toContain("ColineAppProvider");
62
+ expect(main).toContain("AppPage");
63
+ expect(main).toContain('import "./app.css"');
64
+ const generatedFiles = [
65
+ "README.md",
66
+ "AGENTS.md",
67
+ ".agents/skills/coline-app-development/SKILL.md",
68
+ ];
69
+ for (const file of generatedFiles) {
70
+ await expect(readFile(join(dir, file), "utf8")).resolves.not.toContain(
71
+ "/Users/radin/coline-app",
72
+ );
73
+ }
74
+ await expect(
75
+ readFile(join(dir, ".agents/skills/coline-app-development/SKILL.md"), "utf8"),
76
+ ).resolves.toContain("Do not inspect");
53
77
  await expect(readFile(join(dir, ".gitignore"), "utf8")).resolves.toContain("node_modules");
54
78
  });
55
79
 
@@ -97,4 +121,134 @@ describe("runCreate", () => {
97
121
  expect(imported.default.manifest.key).toBe("smoke-app");
98
122
  expect(imported.default.manifest.tools[0]?.name).toBe("smoke-app.create_note");
99
123
  });
124
+
125
+ it("contains a React starter that bundles against the public UI packages", async () => {
126
+ const dir = await scaffoldTemp("react-smoke");
127
+ const packagesRoot = resolve(__dirname, "..", "..");
128
+ const sdkRoot = join(packagesRoot, "sdk");
129
+ const runtimeRoot = join(packagesRoot, "app-runtime");
130
+ const uiRoot = join(packagesRoot, "coline-ui");
131
+ const sdkRequire = createRequire(join(sdkRoot, "package.json"));
132
+ const uiRequire = createRequire(join(uiRoot, "package.json"));
133
+ const result = await build({
134
+ entryPoints: [join(dir, "main.tsx")],
135
+ bundle: true,
136
+ write: false,
137
+ format: "iife",
138
+ platform: "browser",
139
+ target: "es2022",
140
+ jsx: "automatic",
141
+ logLevel: "silent",
142
+ absWorkingDir: packagesRoot,
143
+ nodePaths: [resolve(packagesRoot, "..", "node_modules")],
144
+ alias: {
145
+ "@colineapp/sdk/v2": join(sdkRoot, "src", "v2.ts"),
146
+ "@colineapp/app-runtime": join(runtimeRoot, "src", "index.ts"),
147
+ "@colineapp/ui": join(uiRoot, "src", "index.ts"),
148
+ "@colineapp/ui/styles.css": join(uiRoot, "src", "styles", "base.css"),
149
+ react: uiRequire.resolve("react"),
150
+ "react-dom": uiRequire.resolve("react-dom"),
151
+ "react-dom/client": uiRequire.resolve("react-dom/client"),
152
+ "react/jsx-runtime": uiRequire.resolve("react/jsx-runtime"),
153
+ "zod/v4": sdkRequire.resolve("zod/v4"),
154
+ },
155
+ loader: { ".css": "empty" },
156
+ });
157
+ expect(result.outputFiles?.[0]?.text).toBeTruthy();
158
+ });
159
+ });
160
+
161
+ describe("client artifacts", () => {
162
+ it("keeps application CSS and installed dependencies in the uploaded artifact", async () => {
163
+ const { mkdir } = await import("node:fs/promises");
164
+ const { buildClientSource } = await import("./client-build");
165
+ const dir = await mkdtemp(join(tmpdir(), "cca-css-"));
166
+ cleanups.push(dir);
167
+ await mkdir(join(dir, "node_modules", "my-chart"), { recursive: true });
168
+ await writeFile(
169
+ join(dir, "node_modules/my-chart/package.json"),
170
+ JSON.stringify({ name: "my-chart", main: "index.js" }),
171
+ );
172
+ await writeFile(
173
+ join(dir, "node_modules/my-chart/index.js"),
174
+ 'export const label = "Chart loaded";',
175
+ );
176
+ await writeFile(
177
+ join(dir, "main.tsx"),
178
+ 'import { label } from "my-chart"; import "./app.css"; document.body.textContent = label;',
179
+ );
180
+ await writeFile(join(dir, "app.css"), ".custom-panel { color: rgb(12, 34, 56); }");
181
+ const source = await buildClientSource(dir);
182
+ expect(source.entry).toBe("client.js");
183
+ expect(source.files[source.entry]).toContain("Chart loaded");
184
+ expect(source.files[source.entry]).toContain("custom-panel");
185
+ expect(source.files[source.entry]).toContain("document.head.append");
186
+ });
187
+ });
188
+
189
+ describe("local preview boundary", () => {
190
+ it("serves only local preview routes and closes its watcher", async () => {
191
+ const { startPreview } = await import("./preview");
192
+ const { request } = await import("node:http");
193
+ const dir = await scaffoldTemp("preview-boundary");
194
+ const preview = await startPreview(dir);
195
+ try {
196
+ const html = await fetch(preview.origin).then((response) => response.text());
197
+ expect(html).toContain('sandbox="allow-scripts allow-forms"');
198
+ expect(
199
+ await fetch(`${preview.origin}/app.config.ts`).then((response) => response.status),
200
+ ).toBe(404);
201
+ const status = await new Promise<number | undefined>((done, reject) => {
202
+ const req = request(
203
+ preview.origin,
204
+ { headers: { host: "external.example" } },
205
+ (response) => {
206
+ response.resume();
207
+ done(response.statusCode);
208
+ },
209
+ );
210
+ req.on("error", reject);
211
+ req.end();
212
+ });
213
+ expect(status).toBe(403);
214
+ await expect(startPreview(dir, { pattern: "../../secrets" })).rejects.toThrow(
215
+ "Pattern must be",
216
+ );
217
+ } finally {
218
+ await preview.close();
219
+ }
220
+ });
221
+ });
222
+
223
+ describe("app utility compilation", () => {
224
+ it("generates a new utility from app source and resolves nested CSS assets", async () => {
225
+ const { mkdir, symlink } = await import("node:fs/promises");
226
+ const { dirname } = await import("node:path");
227
+ const { buildClientSource } = await import("./client-build");
228
+ const dir = await mkdtemp(join(tmpdir(), "cca-tailwind-"));
229
+ cleanups.push(dir);
230
+ await mkdir(join(dir, "node_modules"));
231
+ await mkdir(join(dir, "styles"));
232
+ const cliRequire = createRequire(import.meta.url);
233
+ const cssRequire = createRequire(cliRequire.resolve("@tailwindcss/postcss"));
234
+ await symlink(
235
+ dirname(cssRequire.resolve("tailwindcss/package.json")),
236
+ join(dir, "node_modules/tailwindcss"),
237
+ );
238
+ await writeFile(
239
+ join(dir, "main.tsx"),
240
+ 'import "./styles/app.css"; document.body.className = "w-[317px]";',
241
+ );
242
+ await writeFile(
243
+ join(dir, "styles/app.css"),
244
+ '@import "tailwindcss"; .image { background-image: url("./mark.svg"); }',
245
+ );
246
+ await writeFile(
247
+ join(dir, "styles/mark.svg"),
248
+ '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>',
249
+ );
250
+ const source = await buildClientSource(dir);
251
+ expect(source.files[source.entry]).toContain("317px");
252
+ expect(source.files[source.entry]).toContain("data:image/svg+xml");
253
+ });
100
254
  });
package/src/cli.ts CHANGED
@@ -1,14 +1,21 @@
1
+ import { runPreview } from "./preview";
2
+ import { runCheck } from "./check";
1
3
  import { runCreate as create } from "./scaffold";
2
4
  import { runDev } from "./dev";
3
5
  import { runPush } from "./push";
4
6
 
5
- const COLINE_APP_HELP = `coline-app — push and iterate on Coline Apps.
7
+ const COLINE_APP_HELP = `coline-app — build and publish apps for the Coline App Store.
6
8
 
7
9
  Usage:
10
+ coline-app preview [options] Local simulated preview; no credentials
11
+ coline-app check [options] Check types and both bundles without uploading
8
12
  coline-app push [options] Build and upload a new app version
9
13
  coline-app dev [options] Watch the project and re-push on change
10
14
 
11
15
  Options:
16
+ --pattern <name> Preview app, triage, board, or settings
17
+ --screenshots <dir> Capture preview states and report browser errors/overflow
18
+ --port <number> Local preview port (default: automatic)
12
19
  --dir <path> App project directory (default: current directory)
13
20
  --base-url <url> Coline instance URL (default: $COLINE_BASE_URL or https://coline.app)
14
21
  --api-key <key> Workspace API key with the apps.write scope (default: $COLINE_API_KEY)
@@ -27,6 +34,10 @@ export async function runCreate(argv: string[]): Promise<number> {
27
34
  export async function runColineApp(argv: string[]): Promise<number> {
28
35
  const [command, ...rest] = argv;
29
36
  switch (command) {
37
+ case "preview":
38
+ return runPreview(rest);
39
+ case "check":
40
+ return runCheck(rest);
30
41
  case "push":
31
42
  return runPush(rest);
32
43
  case "dev":
@@ -0,0 +1,50 @@
1
+ import { build } from "esbuild";
2
+ import { readFile } from "node:fs/promises";
3
+ import postcss from "postcss";
4
+ import tailwind from "@tailwindcss/postcss";
5
+ import { join, relative, dirname } from "node:path";
6
+
7
+ /** Bundle from the author's installed dependencies, never the private product repo. */
8
+ export async function buildClientSource(
9
+ appDir: string,
10
+ entry = "main.tsx",
11
+ ): Promise<{ entry: string; files: Record<string, string> }> {
12
+ const result = await build({
13
+ entryPoints: [join(appDir, entry)],
14
+ absWorkingDir: appDir,
15
+ plugins: [
16
+ {
17
+ name: "coline-app-css",
18
+ setup(builder) {
19
+ builder.onLoad({ filter: /\.css$/ }, async (args) => {
20
+ const local = relative(appDir, args.path);
21
+ if (local.startsWith("..") || local.split(/[\\/]/).includes("node_modules")) return;
22
+ const source = await readFile(args.path, "utf8");
23
+ const result = await postcss([tailwind({ base: appDir })]).process(source, {
24
+ from: args.path,
25
+ });
26
+ return { contents: result.css, loader: "css", resolveDir: dirname(args.path) };
27
+ });
28
+ },
29
+ },
30
+ ],
31
+ bundle: true,
32
+ write: false,
33
+ outfile: "client.js",
34
+ format: "iife",
35
+ platform: "browser",
36
+ target: "es2022",
37
+ jsx: "automatic",
38
+ minify: true,
39
+ logLevel: "silent",
40
+ define: { "process.env.NODE_ENV": '"production"' },
41
+ loader: { ".svg": "dataurl", ".png": "dataurl", ".jpg": "dataurl", ".woff2": "dataurl" },
42
+ });
43
+ const script = result.outputFiles.find((file) => file.path.endsWith(".js"))?.text;
44
+ const css = result.outputFiles.find((file) => file.path.endsWith(".css"))?.text;
45
+ if (!script) throw new Error("Client build produced no JavaScript.");
46
+ const styles = css
47
+ ? `const style = document.createElement("style"); style.textContent = ${JSON.stringify(css)}; document.head.append(style);\n`
48
+ : "";
49
+ return { entry: "client.js", files: { "client.js": styles + script } };
50
+ }