create-coline-app 2.3.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.
- 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 +67 -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 +39 -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 +173 -0
- package/templates/backendless/examples/settings.tsx +117 -0
- package/templates/backendless/examples/triage.tsx +253 -0
- package/templates/backendless/main.tsx +198 -66
- package/templates/backendless/package.json +10 -5
- package/templates/backendless/preview.seed.ts +26 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
|
|
5
|
+
/** Mechanical browser checks supplement, never replace, looking at the images. */
|
|
6
|
+
export async function capturePreview(origin: string, directory: string): Promise<number> {
|
|
7
|
+
await mkdir(directory, { recursive: true });
|
|
8
|
+
const browser = await chromium.launch().catch((cause: unknown) => {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Chromium could not start. Run npx playwright install chromium. ${String(cause)}`,
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
const results: Array<{ name: string; failures: string[] }> = [];
|
|
14
|
+
try {
|
|
15
|
+
for (const theme of ["light", "dark"]) {
|
|
16
|
+
for (const viewport of [
|
|
17
|
+
{ name: "desktop", width: 1280, height: 800 },
|
|
18
|
+
{ name: "mobile", width: 390, height: 844 },
|
|
19
|
+
]) {
|
|
20
|
+
for (const state of ["ready", "empty", "error", "loading", "denied"]) {
|
|
21
|
+
const name = `${theme}-${viewport.name}-${state}`;
|
|
22
|
+
const failures: string[] = [];
|
|
23
|
+
const page = await browser.newPage({ viewport });
|
|
24
|
+
page.on("pageerror", (error) => failures.push(error.message));
|
|
25
|
+
page.on("console", (message) => {
|
|
26
|
+
if (message.type() === "error") failures.push(message.text());
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
await page.goto(`${origin}/?theme=${theme}&state=${state}`);
|
|
30
|
+
await page.waitForFunction(
|
|
31
|
+
() =>
|
|
32
|
+
document.querySelector('iframe[data-ready="true"]') ||
|
|
33
|
+
document.body.dataset.previewError === "true",
|
|
34
|
+
undefined,
|
|
35
|
+
{ timeout: 30_000 },
|
|
36
|
+
);
|
|
37
|
+
if (await page.locator("body").getAttribute("data-preview-error")) {
|
|
38
|
+
throw new Error(await page.locator("#notice").innerText());
|
|
39
|
+
}
|
|
40
|
+
// Let capabilities settle and fonts/layout paint; loading is deliberately unresolved.
|
|
41
|
+
await page.waitForTimeout(350);
|
|
42
|
+
const frame = page.frames().find((candidate) => candidate.url().includes("/app?"));
|
|
43
|
+
if (!frame) throw new Error("App iframe did not load.");
|
|
44
|
+
failures.push(
|
|
45
|
+
...(await frame.evaluate(() => {
|
|
46
|
+
const issues: string[] = [];
|
|
47
|
+
if (document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)
|
|
48
|
+
issues.push("Page overflows horizontally");
|
|
49
|
+
const controls = document.querySelectorAll<HTMLInputElement>(
|
|
50
|
+
"input:not([type=hidden]),textarea,select",
|
|
51
|
+
);
|
|
52
|
+
for (const control of controls) {
|
|
53
|
+
if (
|
|
54
|
+
!control.labels?.length &&
|
|
55
|
+
!control.getAttribute("aria-label") &&
|
|
56
|
+
!control.getAttribute("aria-labelledby")
|
|
57
|
+
)
|
|
58
|
+
issues.push(`Unlabelled ${control.tagName.toLowerCase()}`);
|
|
59
|
+
}
|
|
60
|
+
return issues;
|
|
61
|
+
})),
|
|
62
|
+
);
|
|
63
|
+
const contentHeight = await frame.evaluate(() => document.documentElement.scrollHeight);
|
|
64
|
+
// Include content below the fold inside the iframe, especially stacked details.
|
|
65
|
+
await page.locator("iframe").evaluate((element, height) => {
|
|
66
|
+
element.style.flex = "none";
|
|
67
|
+
element.style.height = `${Math.min(height, 12000)}px`;
|
|
68
|
+
document.body.style.height = "auto";
|
|
69
|
+
}, contentHeight);
|
|
70
|
+
await page.screenshot({ path: join(directory, `${name}.png`), fullPage: true });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
failures.push(String(error));
|
|
73
|
+
} finally {
|
|
74
|
+
await page.close();
|
|
75
|
+
}
|
|
76
|
+
results.push({ name, failures });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
await browser.close();
|
|
82
|
+
}
|
|
83
|
+
await writeFile(
|
|
84
|
+
join(directory, "report.json"),
|
|
85
|
+
JSON.stringify(
|
|
86
|
+
{
|
|
87
|
+
origin,
|
|
88
|
+
checkedAt: new Date().toISOString(),
|
|
89
|
+
note: "Simulated data. Checks cover browser errors, page overflow, and form labels. Inspect screenshots and test interactions; this is not a visual quality or full accessibility certification.",
|
|
90
|
+
results,
|
|
91
|
+
},
|
|
92
|
+
null,
|
|
93
|
+
2,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
const failed = results.filter((result) => result.failures.length);
|
|
97
|
+
console.log(
|
|
98
|
+
`${results.length} preview captures; ${failed.length} with issues. ${join(directory, "report.json")}`,
|
|
99
|
+
);
|
|
100
|
+
return failed.length ? 1 : 0;
|
|
101
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { stat, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { watch } from "node:fs";
|
|
4
|
+
import { resolve, join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { build } from "esbuild";
|
|
7
|
+
import { buildClientSource } from "./client-build";
|
|
8
|
+
import { argValue } from "./args";
|
|
9
|
+
|
|
10
|
+
const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "assets");
|
|
11
|
+
const scriptSafe = (value: string) => value.replace(/<\/script/gi, "<\\/script");
|
|
12
|
+
const hostHtml = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Coline app preview</title><style>*{box-sizing:border-box}body{display:flex;flex-direction:column;height:100dvh;margin:0;font:12px system-ui;background:#f6f6f6;color:#222}header{flex-shrink:0;display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid #ddd}label{display:flex;gap:5px;align-items:center}select{font:inherit;padding:3px}iframe{display:block;border:0;width:100%;flex:1;min-height:0;background:white}output{color:#666}strong{font-weight:600}</style></head><body><header><strong>Local preview · simulated data</strong><label>Theme<select data-param="theme"><option value="light">Light</option><option value="dark">Dark</option></select></label><label>State<select data-param="state"><option value="ready">Ready</option><option value="empty">Empty</option><option value="error">Error</option><option value="loading">Loading</option><option value="denied">Permission denied</option></select></label><output id="notice">Edits reset on reload. No credentials or uploads.</output></header><iframe title="App preview" sandbox="allow-scripts allow-forms"></iframe><script type="module">import("/host.js").catch(error => { document.querySelector("#notice").textContent = "Preview could not start. Run npm run check and inspect the browser error: " + error.message; document.body.dataset.previewError = "true"; console.error(error); });</script></body></html>`;
|
|
13
|
+
|
|
14
|
+
export async function startPreview(dir: string, options: { port?: number; pattern?: string } = {}) {
|
|
15
|
+
const appDir = resolve(dir);
|
|
16
|
+
const pattern = options.pattern ?? "app";
|
|
17
|
+
if (!["app", "triage", "board", "settings"].includes(pattern))
|
|
18
|
+
throw new Error("Pattern must be app, triage, board, or settings.");
|
|
19
|
+
let revision = 0;
|
|
20
|
+
let cached: Promise<{ host: string; client: string }> | null = null;
|
|
21
|
+
const watcher = watch(appDir, { recursive: true }, (_event, name) => {
|
|
22
|
+
if (
|
|
23
|
+
!name ||
|
|
24
|
+
name
|
|
25
|
+
.split(/[\\/]/)
|
|
26
|
+
.some((part) => ["node_modules", ".git", ".coline", ".cache", "dist"].includes(part))
|
|
27
|
+
)
|
|
28
|
+
return;
|
|
29
|
+
revision++;
|
|
30
|
+
cached = null;
|
|
31
|
+
});
|
|
32
|
+
async function bundles() {
|
|
33
|
+
if (cached) return cached;
|
|
34
|
+
cached = (async () => {
|
|
35
|
+
let entry = "main.tsx";
|
|
36
|
+
if (pattern !== "app") {
|
|
37
|
+
await mkdir(join(appDir, ".coline"), { recursive: true });
|
|
38
|
+
entry = `.coline/preview-${pattern}.tsx`;
|
|
39
|
+
await writeFile(
|
|
40
|
+
join(appDir, entry),
|
|
41
|
+
`import * as React from "react"; import {createRoot} from "react-dom/client"; import {ColineAppProvider} from "@colineapp/ui"; import "@colineapp/ui/styles.css"; import "../app.css"; import {Example} from "../examples/${pattern}"; createRoot(document.getElementById("root")!).render(<ColineAppProvider><Example state={new URLSearchParams(location.search).get("state") ?? "ready"}/></ColineAppProvider>);`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const seedPath = join(appDir, "preview.seed.ts");
|
|
45
|
+
const hasSeed = await stat(seedPath).then(
|
|
46
|
+
() => true,
|
|
47
|
+
() => false,
|
|
48
|
+
);
|
|
49
|
+
const host = await build({
|
|
50
|
+
entryPoints: [join(sourceRoot, "preview-host.ts")],
|
|
51
|
+
bundle: true,
|
|
52
|
+
write: false,
|
|
53
|
+
format: "esm",
|
|
54
|
+
platform: "browser",
|
|
55
|
+
target: "es2022",
|
|
56
|
+
jsx: "automatic",
|
|
57
|
+
logLevel: "silent",
|
|
58
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
59
|
+
plugins: [
|
|
60
|
+
{
|
|
61
|
+
name: "preview-app",
|
|
62
|
+
setup(builder) {
|
|
63
|
+
builder.onResolve({ filter: /^coline-preview-app$/ }, () => ({
|
|
64
|
+
path: join(appDir, "app.config.ts"),
|
|
65
|
+
}));
|
|
66
|
+
builder.onResolve({ filter: /^coline-preview-seed$/ }, () =>
|
|
67
|
+
hasSeed ? { path: seedPath } : { path: "seed", namespace: "preview" },
|
|
68
|
+
);
|
|
69
|
+
builder.onLoad({ filter: /.*/, namespace: "preview" }, () => ({
|
|
70
|
+
contents: "export async function seed() {}",
|
|
71
|
+
loader: "js",
|
|
72
|
+
}));
|
|
73
|
+
builder.onResolve({ filter: /^@colineapp\// }, (args) =>
|
|
74
|
+
args.pluginData?.resolved
|
|
75
|
+
? undefined
|
|
76
|
+
: builder
|
|
77
|
+
.resolve(args.path, {
|
|
78
|
+
kind: "import-statement",
|
|
79
|
+
resolveDir: appDir,
|
|
80
|
+
pluginData: { resolved: true },
|
|
81
|
+
})
|
|
82
|
+
.then((result) => ({ path: result.path, errors: result.errors })),
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
const client = await buildClientSource(appDir, entry);
|
|
89
|
+
return { host: host.outputFiles[0]!.text, client: client.files[client.entry]! };
|
|
90
|
+
})().catch((error) => {
|
|
91
|
+
cached = null;
|
|
92
|
+
throw error;
|
|
93
|
+
});
|
|
94
|
+
return cached;
|
|
95
|
+
}
|
|
96
|
+
const server = createServer(async (request, response) => {
|
|
97
|
+
const host = request.headers.host ?? "";
|
|
98
|
+
if (!/^127\.0\.0\.1:\d+$/.test(host) && !/^localhost:\d+$/.test(host)) {
|
|
99
|
+
response.writeHead(403);
|
|
100
|
+
response.end("Local preview only");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const url = new URL(request.url ?? "/", `http://${host}`);
|
|
104
|
+
response.setHeader("Cache-Control", "no-store");
|
|
105
|
+
try {
|
|
106
|
+
if (url.pathname === "/revision") {
|
|
107
|
+
response.end(String(revision));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (url.pathname === "/") {
|
|
111
|
+
response.setHeader("Content-Type", "text/html");
|
|
112
|
+
response.end(hostHtml);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (url.pathname === "/host.js") {
|
|
116
|
+
response.setHeader("Content-Type", "text/javascript");
|
|
117
|
+
response.end((await bundles()).host);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (url.pathname === "/app") {
|
|
121
|
+
response.setHeader("Content-Type", "text/html");
|
|
122
|
+
response.setHeader(
|
|
123
|
+
"Content-Security-Policy",
|
|
124
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'",
|
|
125
|
+
);
|
|
126
|
+
response.end(
|
|
127
|
+
`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script>${scriptSafe((await bundles()).client)}</script></body></html>`,
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
response.writeHead(404);
|
|
132
|
+
response.end("Not found");
|
|
133
|
+
} catch (error) {
|
|
134
|
+
response.writeHead(500, { "Content-Type": "text/plain" });
|
|
135
|
+
response.end(error instanceof Error ? error.message : String(error));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
await new Promise<void>((resolveReady, reject) => {
|
|
140
|
+
server.once("error", reject);
|
|
141
|
+
server.listen(options.port ?? 0, "127.0.0.1", resolveReady);
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
watcher.close();
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const address = server.address();
|
|
148
|
+
if (!address || typeof address === "string") throw new Error("Preview failed to bind.");
|
|
149
|
+
return {
|
|
150
|
+
origin: `http://127.0.0.1:${address.port}`,
|
|
151
|
+
server,
|
|
152
|
+
close: async () => {
|
|
153
|
+
watcher.close();
|
|
154
|
+
server.closeAllConnections();
|
|
155
|
+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function runPreview(argv: string[]) {
|
|
161
|
+
const port = argValue(argv, "--port");
|
|
162
|
+
if (port && (!/^\d+$/.test(port) || Number(port) > 65535))
|
|
163
|
+
throw new Error("Port must be 0–65535.");
|
|
164
|
+
const preview = await startPreview(resolve(argValue(argv, "--dir") ?? "."), {
|
|
165
|
+
port: port ? Number(port) : 0,
|
|
166
|
+
pattern: argValue(argv, "--pattern") ?? "app",
|
|
167
|
+
});
|
|
168
|
+
console.log(
|
|
169
|
+
`Local preview: ${preview.origin}\nSimulated data. No credentials, uploads, or production writes. Reloading resets fixtures.`,
|
|
170
|
+
);
|
|
171
|
+
const screenshots = argValue(argv, "--screenshots");
|
|
172
|
+
if (screenshots) {
|
|
173
|
+
try {
|
|
174
|
+
const { capturePreview } = await import("./preview-capture");
|
|
175
|
+
return await capturePreview(preview.origin, resolve(screenshots));
|
|
176
|
+
} finally {
|
|
177
|
+
await preview.close();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return new Promise<number>((resolveExit) => {
|
|
181
|
+
const stop = () => {
|
|
182
|
+
process.off("SIGINT", stop);
|
|
183
|
+
process.off("SIGTERM", stop);
|
|
184
|
+
void preview.close().then(() => resolveExit(0));
|
|
185
|
+
};
|
|
186
|
+
process.on("SIGINT", stop);
|
|
187
|
+
process.on("SIGTERM", stop);
|
|
188
|
+
});
|
|
189
|
+
}
|
package/src/push.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { buildClientSource } from "./client-build";
|
|
1
2
|
import { build } from "esbuild";
|
|
2
|
-
import { readFile
|
|
3
|
-
import { join,
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
4
5
|
import { pathToFileURL } from "node:url";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
@@ -29,9 +30,6 @@ export interface PushResult {
|
|
|
29
30
|
versionId: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".css"]);
|
|
33
|
-
const IGNORED_DIRECTORIES = new Set(["node_modules", "dist", ".git", ".cache", ".coline"]);
|
|
34
|
-
|
|
35
33
|
export function resolvePushConfig(argv: readonly string[]): PushConfig {
|
|
36
34
|
const apiKey = argValue(argv, "--api-key") ?? process.env.COLINE_API_KEY ?? null;
|
|
37
35
|
if (!apiKey) {
|
|
@@ -53,7 +51,7 @@ export function resolvePushConfig(argv: readonly string[]): PushConfig {
|
|
|
53
51
|
};
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
async function buildLogicBundle(appDir: string): Promise<string> {
|
|
54
|
+
export async function buildLogicBundle(appDir: string): Promise<string> {
|
|
57
55
|
const result = await build({
|
|
58
56
|
entryPoints: [join(appDir, "app.config.ts")],
|
|
59
57
|
bundle: true,
|
|
@@ -78,7 +76,7 @@ async function buildLogicBundle(appDir: string): Promise<string> {
|
|
|
78
76
|
* (plan §6.2 two-artifact model). The app's SDK version travels inside
|
|
79
77
|
* the bundle, so the CLI never needs to agree with it.
|
|
80
78
|
*/
|
|
81
|
-
async function extractManifest(logicBundleJs: string): Promise<ManifestSummary> {
|
|
79
|
+
export async function extractManifest(logicBundleJs: string): Promise<ManifestSummary> {
|
|
82
80
|
const dir = await mkdtemp(join(tmpdir(), "coline-app-"));
|
|
83
81
|
const bundlePath = join(dir, "app.config.mjs");
|
|
84
82
|
try {
|
|
@@ -88,9 +86,7 @@ async function extractManifest(logicBundleJs: string): Promise<ManifestSummary>
|
|
|
88
86
|
};
|
|
89
87
|
const manifest = imported.default?.manifest;
|
|
90
88
|
if (!manifest || typeof manifest !== "object") {
|
|
91
|
-
throw new Error(
|
|
92
|
-
"app.config.ts must default-export defineApp(...); no manifest was found.",
|
|
93
|
-
);
|
|
89
|
+
throw new Error("app.config.ts must default-export defineApp(...); no manifest was found.");
|
|
94
90
|
}
|
|
95
91
|
return JSON.parse(JSON.stringify(manifest)) as ManifestSummary;
|
|
96
92
|
} finally {
|
|
@@ -108,39 +104,6 @@ function manifestHasReactSurfaces(manifest: ManifestSummary): boolean {
|
|
|
108
104
|
return tiers.includes("react");
|
|
109
105
|
}
|
|
110
106
|
|
|
111
|
-
async function collectClientSource(
|
|
112
|
-
appDir: string,
|
|
113
|
-
): Promise<{ entry: string; files: Record<string, string> } | null> {
|
|
114
|
-
const entry = "main.tsx";
|
|
115
|
-
const entryExists = await stat(join(appDir, entry)).then(
|
|
116
|
-
(item) => item.isFile(),
|
|
117
|
-
() => false,
|
|
118
|
-
);
|
|
119
|
-
if (!entryExists) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const files: Record<string, string> = {};
|
|
124
|
-
async function walk(dir: string): Promise<void> {
|
|
125
|
-
for (const item of await readdir(dir, { withFileTypes: true })) {
|
|
126
|
-
if (item.isDirectory()) {
|
|
127
|
-
if (!IGNORED_DIRECTORIES.has(item.name)) {
|
|
128
|
-
await walk(join(dir, item.name));
|
|
129
|
-
}
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const extension = item.name.slice(item.name.lastIndexOf("."));
|
|
133
|
-
if (!SOURCE_EXTENSIONS.has(extension) || item.name.endsWith(".test.ts")) {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
const filePath = join(dir, item.name);
|
|
137
|
-
files[relative(appDir, filePath)] = await readFile(filePath, "utf8");
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
await walk(appDir);
|
|
141
|
-
return { entry, files };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
107
|
async function resolveVersion(appDir: string, explicit: string | null): Promise<string> {
|
|
145
108
|
if (explicit) {
|
|
146
109
|
return explicit;
|
|
@@ -160,13 +123,11 @@ export async function pushAppVersion(config: PushConfig): Promise<PushResult> {
|
|
|
160
123
|
const manifest = await extractManifest(logicBundleJs);
|
|
161
124
|
const version = await resolveVersion(config.dir, config.version);
|
|
162
125
|
const clientSource = manifestHasReactSurfaces(manifest)
|
|
163
|
-
? await
|
|
126
|
+
? await buildClientSource(config.dir)
|
|
164
127
|
: null;
|
|
165
128
|
|
|
166
129
|
if (manifestHasReactSurfaces(manifest) && !clientSource) {
|
|
167
|
-
throw new Error(
|
|
168
|
-
"The manifest declares react surfaces but no main.tsx client entry was found.",
|
|
169
|
-
);
|
|
130
|
+
throw new Error("The manifest declares react surfaces but no main.tsx client entry was found.");
|
|
170
131
|
}
|
|
171
132
|
|
|
172
133
|
const response = await fetch(
|
package/src/scaffold.ts
CHANGED
|
@@ -62,6 +62,7 @@ export async function runCreate(argv: string[]): Promise<number> {
|
|
|
62
62
|
await rename(shippedGitignore, join(targetDir, ".gitignore")).catch(() => undefined);
|
|
63
63
|
|
|
64
64
|
for (const relative of await walkFiles(targetDir)) {
|
|
65
|
+
if (/\.(png|jpg|jpeg|webp|gif|woff2?)$/i.test(relative)) continue;
|
|
65
66
|
const filePath = join(targetDir, relative);
|
|
66
67
|
const content = await readFile(filePath, "utf8");
|
|
67
68
|
if (content.includes("__APP_KEY__") || content.includes("__APP_NAME__")) {
|
|
@@ -79,6 +80,11 @@ Next steps:
|
|
|
79
80
|
cd ${target}
|
|
80
81
|
npm install
|
|
81
82
|
npm test
|
|
83
|
+
npm run check
|
|
84
|
+
npm run preview
|
|
85
|
+
|
|
86
|
+
Preview uses disposable local data, with no credentials required.
|
|
87
|
+
Read AGENTS.md for design examples and the bundled experimental UI skills.
|
|
82
88
|
|
|
83
89
|
Then create a workspace API key with the apps.write scope in
|
|
84
90
|
Workspace Settings → API, and push your first version:
|
|
@@ -1,70 +1,36 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: coline-app-development
|
|
3
|
-
description: Build and debug a Coline App using
|
|
3
|
+
description: Build and debug a Coline App using its public SDK, capability permissions, hosted logic, sandboxed React, and local preview. Use for changes to app data, tools, surfaces, or integration.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Coline App development
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
## Public SDK rules
|
|
38
|
-
|
|
39
|
-
- Import logic from `@colineapp/sdk/v2` and tests from `@colineapp/sdk/testing`.
|
|
40
|
-
- Prefix every tool name with the app key.
|
|
41
|
-
- Validate inputs with `zod/v4` and declare honest effects (`read`, `write`,
|
|
42
|
-
`destructive`, or `external`).
|
|
43
|
-
- Ask for the minimum permissions. Capability calls still require both the
|
|
44
|
-
declared permission and the tool effect ceiling.
|
|
45
|
-
- Collections are typed record envelopes. `where` is exact-match on top-level
|
|
46
|
-
fields; query bounded pages and filter more complex predicates in memory.
|
|
47
|
-
- Use `ui.*` for hosted handlers. Chat tool cards must stay within the
|
|
48
|
-
validated chat subset and remain small.
|
|
49
|
-
|
|
50
|
-
## UI rules
|
|
51
|
-
|
|
52
|
-
- Import components from `@colineapp/ui` and `@colineapp/ui/styles.css`.
|
|
53
|
-
- Use the host theme tokens (`bg-background`, `text-foreground`,
|
|
54
|
-
`text-muted-foreground`, and related tokens); dark mode is supplied by the
|
|
55
|
-
host.
|
|
56
|
-
- Render explicit loading, empty, and error states.
|
|
57
|
-
- Keep components accessible and use the provided controls before writing
|
|
58
|
-
custom primitives.
|
|
59
|
-
- Do not add a state library or network client for the first implementation.
|
|
60
|
-
|
|
61
|
-
## Verify before pushing
|
|
62
|
-
|
|
63
|
-
```sh
|
|
64
|
-
npm run typecheck
|
|
65
|
-
npm test
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Then push with `npx coline-app dev --internal` or `npx coline-app push --internal`
|
|
69
|
-
when the user has supplied `COLINE_BASE_URL` and `COLINE_API_KEY`. The local
|
|
70
|
-
test workspace is the first proof; the hosted app is the second.
|
|
8
|
+
Read `AGENTS.md`, `app.config.ts`, and the installed package declarations.
|
|
9
|
+
Do not inspect private Coline repositories or invent capability signatures.
|
|
10
|
+
|
|
11
|
+
1. Keep logic in `app.config.ts` and the React entry inside `ColineAppProvider`.
|
|
12
|
+
Import `@colineapp/ui/styles.css` followed by `./app.css`.
|
|
13
|
+
2. Choose persistence deliberately: files for user documents; app collections
|
|
14
|
+
for structured records. Use `useLiveCollection` for collaborative record views,
|
|
15
|
+
`useColineQuery` for bounded other reads, and React state for transient controls.
|
|
16
|
+
3. Declare the minimum permission and honest tool effect. Validate tool input
|
|
17
|
+
with `zod/v4`; tools use the app-key prefix. Share actions through
|
|
18
|
+
`coline.tools.invoke` rather than reimplementing a tool in the UI.
|
|
19
|
+
4. Save files with `expectedVersion`. Catch rejected mutations and preserve
|
|
20
|
+
unsaved input. Permission-denied and conflict errors need different recovery;
|
|
21
|
+
never turn a conflict into a silent overwrite.
|
|
22
|
+
5. Keep network access behind `coline.net.fetch` and the manifest allowlist.
|
|
23
|
+
No direct fetch, credentials, localStorage, or private API routes in the iframe.
|
|
24
|
+
6. Test logic with `createTestWorkspace(app)`. Use explicit members and
|
|
25
|
+
`clientAs(userId)` for actor cases. Check installed types for query operators,
|
|
26
|
+
pagination, ACLs, and capability argument shapes.
|
|
27
|
+
7. Run `npm test`, `npm run check`, and local preview. For UI work, also follow
|
|
28
|
+
the adjacent design and review skills. Only use upload commands when in scope.
|
|
29
|
+
|
|
30
|
+
Preview is an in-memory workspace, not a hosted permission/performance test.
|
|
31
|
+
Its native prompt-based pickers are intentionally simple. External service
|
|
32
|
+
calls need explicit test handlers in logic tests; they are not real integrations.
|
|
33
|
+
|
|
34
|
+
When replacing example React state with persistent records, read
|
|
35
|
+
[the live-record recipe](references/live-records.md). It covers envelope data,
|
|
36
|
+
nullable mutation results, draft preservation, pending states, and fixture seeding.
|
package/templates/backendless/.agents/skills/coline-app-development/references/live-records.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Connect a screen to saved records
|
|
2
|
+
|
|
3
|
+
The manifest needs `storage.app`. Add `realtime.subscribe` for live hints.
|
|
4
|
+
In `ColineAppProvider`, use the public hook:
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
type Story = { title: string; stage: string; description: string };
|
|
8
|
+
const stories = useLiveCollection<Story>("stories", { limit: 100 });
|
|
9
|
+
// Each envelope has id, data, version, and access. Render record.data.title.
|
|
10
|
+
const visible = stories.records.filter(record => record.data.stage === stage);
|
|
11
|
+
|
|
12
|
+
async function create() {
|
|
13
|
+
setError(null);
|
|
14
|
+
setSaving(true);
|
|
15
|
+
try {
|
|
16
|
+
const record = await stories.insert(
|
|
17
|
+
{ title: title.trim(), stage: "Exploring", description },
|
|
18
|
+
{ workspace: "write", readers: [], writers: [] },
|
|
19
|
+
);
|
|
20
|
+
if (!record) throw new Error("The story was not saved. Your draft is still here.");
|
|
21
|
+
setTitle(""); // Clear drafts only after success.
|
|
22
|
+
} catch (cause) {
|
|
23
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
24
|
+
} finally { setSaving(false); }
|
|
25
|
+
}
|
|
26
|
+
async function move(id: string, stage: string) {
|
|
27
|
+
try {
|
|
28
|
+
if (!await stories.update(id, { stage })) throw new Error("The story is no longer available.");
|
|
29
|
+
} catch (cause) {
|
|
30
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`title`, `description`, `saving`, and `error` above are ordinary React form state.
|
|
36
|
+
Keep the draft on failure; don't blindly retry a conflict. `stories.pending` is
|
|
37
|
+
a count; `stories.status === "loading"` is initial loading. Show
|
|
38
|
+
`stories.error.message` and offer `stories.refresh()` for read recovery.
|
|
39
|
+
A bounded view's search only covers loaded records. Disclose `nextCursor` or
|
|
40
|
+
implement pagination rather than claiming the entire collection was searched.
|
|
41
|
+
|
|
42
|
+
Seed in `preview.seed.ts` with the direct capability client. Its options shape
|
|
43
|
+
differs from the hook's second argument:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
await workspace.client().storage.collection<Story>("stories").insert(
|
|
47
|
+
{ title: "A calmer workday", stage: "Exploring", description: "A useful story." },
|
|
48
|
+
{ access: { workspace: "write", readers: [], writers: [] } },
|
|
49
|
+
);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Preview fixtures reset on reload. Real hosted collections persist; a local
|
|
53
|
+
preview passing does not prove hosted persistence or multi-user permissions.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: coline-ui-design
|
|
3
|
+
description: Design or improve a Coline App interface using working screen examples, host theme tokens, responsive patterns, and realistic states. Use when building pages, forms, boards, queues, editors, or improving unattractive UI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Coline UI design — experimental
|
|
7
|
+
|
|
8
|
+
Make the user's main task obvious before adding decoration. Preserve an existing
|
|
9
|
+
product design when present. This is a starting path, not a mandatory visual style.
|
|
10
|
+
|
|
11
|
+
## Choose one useful starting point
|
|
12
|
+
|
|
13
|
+
Write three short decisions: who uses this, what they do most, and what belongs
|
|
14
|
+
on the first screen. Then inspect the nearest working example:
|
|
15
|
+
|
|
16
|
+
| Main job | Start from | Preview command |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| Review many items and act on one | `examples/triage.tsx` | `npm run preview -- --pattern triage` |
|
|
19
|
+
| Move work through stages | `examples/board.tsx` | `npm run preview -- --pattern board` |
|
|
20
|
+
| Configure preferences | `examples/settings.tsx` | `npm run preview -- --pattern settings` |
|
|
21
|
+
| Read/write documents | `main.tsx` | `npm run preview` |
|
|
22
|
+
|
|
23
|
+
Paths are relative to the app root. Reference screenshots live in this skill's
|
|
24
|
+
`assets/` directory. View them if your model supports images. The source examples
|
|
25
|
+
are the complete reference for text-only agents.
|
|
26
|
+
|
|
27
|
+
For a small context budget: copy ONE example, keep its layout and state handling,
|
|
28
|
+
change the domain objects and copy, then connect SDK data. For a distinctive
|
|
29
|
+
product: compose the exported patterns or plain CSS deliberately. Do not force
|
|
30
|
+
an editorial website, calendar, map, or canvas into a queue just because it exists.
|
|
31
|
+
|
|
32
|
+
## Compose the screen
|
|
33
|
+
|
|
34
|
+
- `AppPage`: title, short description, one main action; wide or reading width.
|
|
35
|
+
- `PageToolbar`: search/filter controls near the content they change.
|
|
36
|
+
- `RecordTable` + `SplitView` + `DetailPanel`: scan, select, inspect, act.
|
|
37
|
+
- `Board` + `BoardColumn` + `BoardCard`: visible stages; explicit move controls
|
|
38
|
+
work with keyboard and touch. These components do not implement drag and drop.
|
|
39
|
+
- `SettingsSection`: separate explanation from labelled fields.
|
|
40
|
+
- `EmptyState`, `AppLoadingState`, `AppErrorState`: useful non-happy paths.
|
|
41
|
+
- `StatusBadge`: restrained semantic status with text, never color alone.
|
|
42
|
+
|
|
43
|
+
See installed `@colineapp/ui` declarations for props. Keep tables semantic; the
|
|
44
|
+
first cell becomes a button when `onOpen` is supplied, so don't nest controls there.
|
|
45
|
+
Move focus to opened details and return it when closing. When a move remounts a
|
|
46
|
+
record in another column, restore focus to that record after rendering (and after
|
|
47
|
+
pending controls are enabled). Keep horizontal scrolling
|
|
48
|
+
inside boards/tables, not on the whole page.
|
|
49
|
+
|
|
50
|
+
Use host tokens and exported controls. `app.css` compiles Tailwind automatically;
|
|
51
|
+
write complete class names or a fixed map, not dynamically assembled utilities.
|
|
52
|
+
Choose a compact consistent spacing scale, readable line lengths, and muted
|
|
53
|
+
secondary text. Use color for meaning. Use one clear control for each action: a stage selector
|
|
54
|
+
does not also need previous/next arrows on every card. Keep controls compact
|
|
55
|
+
without shrinking touch targets or letting field labels stretch adjacent buttons.
|
|
56
|
+
Don't add stat cards, giant greetings,
|
|
57
|
+
gradients, decorative charts, or sidebars unless the user's job calls for them.
|
|
58
|
+
|
|
59
|
+
## Make it real
|
|
60
|
+
|
|
61
|
+
Replace example state with capabilities before calling the app complete. Seed
|
|
62
|
+
believable names, long titles, and varied statuses in `preview.seed.ts`. Every
|
|
63
|
+
visible action must work; preserve input on errors and show saving/disabled
|
|
64
|
+
states. No pretend search, dead menus, invented metrics, or static success toasts.
|
|
65
|
+
|
|
66
|
+
Run the adjacent `coline-ui-review` skill before finishing. If your model cannot
|
|
67
|
+
inspect images, say so; mechanical checks alone do not establish visual quality.
|