create-coline-app 2.3.0 → 2.5.1

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 +26 -2
  2. package/README.md +51 -4
  3. package/assets/preview-host.ts +130 -0
  4. package/bin/_run.mjs +11 -2
  5. package/package.json +8 -4
  6. package/src/check.ts +46 -0
  7. package/src/cli.test.ts +112 -6
  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 +30 -64
  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 +89 -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 +47 -0
  22. package/templates/backendless/AGENTS.md +70 -158
  23. package/templates/backendless/CLAUDE.md +1 -6
  24. package/templates/backendless/README.md +22 -29
  25. package/templates/backendless/app.config.ts +12 -8
  26. package/templates/backendless/app.css +84 -0
  27. package/templates/backendless/examples/board.tsx +168 -0
  28. package/templates/backendless/examples/settings.tsx +113 -0
  29. package/templates/backendless/examples/triage.tsx +243 -0
  30. package/templates/backendless/main.tsx +198 -66
  31. package/templates/backendless/package.json +10 -5
  32. package/templates/backendless/preview.seed.ts +26 -0
package/AGENTS.md CHANGED
@@ -26,7 +26,7 @@ Keep these versions aligned in one release:
26
26
 
27
27
  - `@colineapp/sdk` — logic, manifest, capabilities, tree UI, test workspace
28
28
  - `@colineapp/ui` — React provider, hooks, components, stylesheet
29
- - `@colineapp/app-runtime` — transitive guest/host bridge dependency
29
+ - `@colineapp/app-runtime` — guest/host bridge; direct scaffold dev dependency for local preview
30
30
  - `create-coline-app` — scaffold and push/dev CLI
31
31
 
32
32
  When any public API changes, update the package README, package `AGENTS.md`,
@@ -57,6 +57,30 @@ smoke test together.
57
57
 
58
58
  ## Release hygiene
59
59
 
60
- The package publishes `bin`, `src`, `templates`, `README.md`, `AGENTS.md`, and
60
+ The package publishes `bin`, `src`, `assets`, `templates`, `README.md`, `AGENTS.md`, and
61
61
  `LICENSE`. Bump the CLI version when the template contract changes. Publish
62
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
@@ -15,14 +15,61 @@ Two binaries ship from this package:
15
15
  agent guidance, and tests against `@colineapp/sdk/testing`.
16
16
  - **`coline-app push | dev`** — builds the logic bundle with esbuild,
17
17
  extracts the manifest through the two-artifact model (plan §6.2),
18
- collects client source when the manifest declares react surfaces, and
18
+ bundles installed client dependencies and CSS when the manifest declares React surfaces, and
19
19
  uploads to `POST /api/v1/apps/{key}/versions` with a workspace API key
20
20
  (`apps.write` scope). `dev` watches and re-pushes stamped versions.
21
21
 
22
- The CLI never builds tier-2 client bundles itself it ships source and
23
- 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
24
 
25
25
  The generated project includes `AGENTS.md` and
26
26
  `.agents/skills/coline-app-development/SKILL.md`. Those files are deliberately
27
27
  specific to the installed public packages so coding agents can work without
28
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.3.0",
4
- "description": "Scaffold, push, and iterate on Coline Apps.",
3
+ "version": "2.5.1",
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",
@@ -13,10 +13,14 @@
13
13
  "templates",
14
14
  "README.md",
15
15
  "AGENTS.md",
16
- "LICENSE"
16
+ "LICENSE",
17
+ "assets"
17
18
  ],
18
19
  "dependencies": {
19
- "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"
20
24
  },
21
25
  "devDependencies": {
22
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
@@ -52,14 +52,25 @@ describe("runCreate", () => {
52
52
  devDependencies: Record<string, string>;
53
53
  };
54
54
  expect(packageJson.name).toBe("team-crm");
55
- expect(packageJson.dependencies["@colineapp/sdk"]).toBe("^0.3.1");
56
- expect(packageJson.dependencies["@colineapp/ui"]).toBe("^0.3.1");
55
+ expect(packageJson.dependencies["@colineapp/sdk"]).toBe("^0.4.0");
56
+ expect(packageJson.dependencies["@colineapp/ui"]).toBe("^0.4.1");
57
57
  expect(packageJson.dependencies.react).toBe("^19.0.0");
58
58
  expect(packageJson.dependencies["react-dom"]).toBe("^19.0.0");
59
- expect(packageJson.devDependencies["create-coline-app"]).toBe("^2.3.0");
60
- await expect(readFile(join(dir, "main.tsx"), "utf8")).resolves.toContain(
61
- "ColineAppProvider",
62
- );
59
+ expect(packageJson.devDependencies["create-coline-app"]).toBe("^2.5.1");
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
+ }
63
74
  await expect(
64
75
  readFile(join(dir, ".agents/skills/coline-app-development/SKILL.md"), "utf8"),
65
76
  ).resolves.toContain("Do not inspect");
@@ -146,3 +157,98 @@ describe("runCreate", () => {
146
157
  expect(result.outputFiles?.[0]?.text).toBeTruthy();
147
158
  });
148
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
+ });
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
+ }
package/src/dev.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { watch } from "node:fs";
2
- import { join } from "node:path";
3
2
  import { pushAppVersion, resolvePushConfig } from "./push";
4
3
 
5
4
  const DEBOUNCE_MS = 400;
6
5
 
7
6
  export async function runDev(argv: string[]): Promise<number> {
8
7
  const config = resolvePushConfig(argv);
9
- console.log(`Watching ${config.dir} — every change pushes a new dev version.`);
8
+ console.log(
9
+ `Watching ${config.dir} — changes update the same dev version; reload the app to see them.`,
10
+ );
10
11
 
11
12
  let timer: ReturnType<typeof setTimeout> | null = null;
12
13
  let pushing = false;
@@ -42,9 +43,9 @@ export async function runDev(argv: string[]): Promise<number> {
42
43
  return;
43
44
  }
44
45
  if (
45
- fileName.includes("node_modules") ||
46
- fileName.includes(".git") ||
47
- fileName.includes(join(".cache", ""))
46
+ fileName
47
+ .split(/[\\/]/)
48
+ .some((part) => ["node_modules", ".git", ".cache", "dist", ".coline"].includes(part))
48
49
  ) {
49
50
  return;
50
51
  }