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.
- package/AGENTS.md +26 -2
- package/README.md +51 -4
- package/assets/preview-host.ts +130 -0
- package/bin/_run.mjs +11 -2
- package/package.json +8 -4
- package/src/check.ts +46 -0
- package/src/cli.test.ts +112 -6
- package/src/cli.ts +12 -1
- package/src/client-build.ts +50 -0
- package/src/dev.ts +6 -5
- package/src/preview-capture.ts +101 -0
- package/src/preview.ts +189 -0
- package/src/push.ts +8 -47
- package/src/scaffold.ts +6 -0
- package/templates/backendless/.agents/skills/coline-app-development/SKILL.md +30 -64
- package/templates/backendless/.agents/skills/coline-app-development/references/live-records.md +53 -0
- package/templates/backendless/.agents/skills/coline-ui-design/SKILL.md +89 -0
- package/templates/backendless/.agents/skills/coline-ui-design/assets/board.png +0 -0
- package/templates/backendless/.agents/skills/coline-ui-design/assets/settings.png +0 -0
- package/templates/backendless/.agents/skills/coline-ui-design/assets/triage.png +0 -0
- package/templates/backendless/.agents/skills/coline-ui-review/SKILL.md +47 -0
- package/templates/backendless/AGENTS.md +70 -158
- package/templates/backendless/CLAUDE.md +1 -6
- package/templates/backendless/README.md +22 -29
- package/templates/backendless/app.config.ts +12 -8
- package/templates/backendless/app.css +84 -0
- package/templates/backendless/examples/board.tsx +168 -0
- package/templates/backendless/examples/settings.tsx +113 -0
- package/templates/backendless/examples/triage.tsx +243 -0
- package/templates/backendless/main.tsx +198 -66
- package/templates/backendless/package.json +10 -5
- 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` —
|
|
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
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "Scaffold, push, and iterate on Coline
|
|
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.
|
|
56
|
-
expect(packageJson.dependencies["@colineapp/ui"]).toBe("^0.
|
|
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.
|
|
60
|
-
await
|
|
61
|
-
|
|
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 —
|
|
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(
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
fileName
|
|
47
|
+
.split(/[\\/]/)
|
|
48
|
+
.some((part) => ["node_modules", ".git", ".cache", "dist", ".coline"].includes(part))
|
|
48
49
|
) {
|
|
49
50
|
return;
|
|
50
51
|
}
|