create-coline-app 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Coline
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # create-coline-app
2
+
3
+ Scaffold, push, and iterate on Coline Apps.
4
+
5
+ ```sh
6
+ npx create-coline-app my-app
7
+ cd my-app && npm install && npm test
8
+ ```
9
+
10
+ Two binaries ship from this package:
11
+
12
+ - **`create-coline-app <dir>`** — scaffolds a backendless app: a
13
+ `defineApp` config with a Kairo tool, a file type with tree surfaces,
14
+ a hosted home surface, and tests against `@colineapp/sdk/testing`.
15
+ - **`coline-app push | dev`** — builds the logic bundle with esbuild,
16
+ extracts the manifest through the two-artifact model (plan §6.2),
17
+ collects client source when the manifest declares react surfaces, and
18
+ uploads to `POST /api/v1/apps/{key}/versions` with a workspace API key
19
+ (`apps.write` scope). `dev` watches and re-pushes stamped versions.
20
+
21
+ The CLI never builds tier-2 client bundles itself — it ships source and
22
+ Coline's reviewed build pipeline produces the bundle.
package/bin/_run.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // Bin shim: bundles the strict-TS CLI source with esbuild on first run,
2
+ // caches it inside the package, and dispatches to the named entrypoint.
3
+ import { build } from "esbuild";
4
+ import { createHash } from "node:crypto";
5
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+
9
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
10
+
11
+ async function ensureCliBundle() {
12
+ const entry = join(packageRoot, "src", "cli.ts");
13
+ const source = await readFile(entry, "utf8");
14
+ const stamp = createHash("sha256").update(source).digest("hex").slice(0, 16);
15
+ const outFile = join(packageRoot, ".cache", `cli.${stamp}.mjs`);
16
+ const exists = await stat(outFile).then(
17
+ () => true,
18
+ () => false,
19
+ );
20
+ if (!exists) {
21
+ const result = await build({
22
+ entryPoints: [entry],
23
+ bundle: true,
24
+ write: false,
25
+ format: "esm",
26
+ platform: "node",
27
+ target: "node20",
28
+ packages: "external",
29
+ logLevel: "silent",
30
+ });
31
+ await mkdir(dirname(outFile), { recursive: true });
32
+ await writeFile(outFile, result.outputFiles[0].text, "utf8");
33
+ }
34
+ return pathToFileURL(outFile).href;
35
+ }
36
+
37
+ export async function runFromBin(entrypoint) {
38
+ const moduleUrl = await ensureCliBundle();
39
+ const cli = await import(moduleUrl);
40
+ const run = cli[entrypoint];
41
+ if (typeof run !== "function") {
42
+ console.error(`CLI entrypoint "${entrypoint}" not found.`);
43
+ process.exit(1);
44
+ }
45
+ try {
46
+ const code = await run(process.argv.slice(2));
47
+ process.exit(typeof code === "number" ? code : 0);
48
+ } catch (error) {
49
+ console.error(error instanceof Error ? error.message : String(error));
50
+ process.exit(1);
51
+ }
52
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runFromBin } from "./_run.mjs";
3
+
4
+ await runFromBin("runColineApp");
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runFromBin } from "./_run.mjs";
3
+
4
+ await runFromBin("runCreate");
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "create-coline-app",
3
+ "version": "2.0.0",
4
+ "description": "Scaffold, push, and iterate on Coline Apps.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-coline-app": "./bin/create-coline-app.mjs",
8
+ "coline-app": "./bin/coline-app.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "templates",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "dependencies": {
18
+ "esbuild": "^0.25.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.10.2",
22
+ "typescript": "^5.7.2",
23
+ "vitest": "^3.0.5"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/ColineApp/coline-app.git",
29
+ "directory": "packages/create-coline-app"
30
+ },
31
+ "homepage": "https://coline.app/developers/docs",
32
+ "keywords": [
33
+ "coline",
34
+ "apps",
35
+ "cli",
36
+ "scaffold",
37
+ "create"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run"
45
+ }
46
+ }
package/src/args.ts ADDED
@@ -0,0 +1,12 @@
1
+ export function argValue(argv: readonly string[], flag: string): string | null {
2
+ const index = argv.indexOf(flag);
3
+ if (index < 0) {
4
+ return null;
5
+ }
6
+ const value = argv[index + 1];
7
+ return value !== undefined && !value.startsWith("--") ? value : null;
8
+ }
9
+
10
+ export function hasFlag(argv: readonly string[], flag: string): boolean {
11
+ return argv.includes(flag);
12
+ }
@@ -0,0 +1,100 @@
1
+ import { build } from "esbuild";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+ import { argValue, hasFlag } from "./args";
9
+ import { runCreate } from "./scaffold";
10
+
11
+ const cleanups: string[] = [];
12
+
13
+ afterEach(async () => {
14
+ for (const dir of cleanups.splice(0)) {
15
+ await rm(dir, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ async function scaffoldTemp(name: string): Promise<string> {
20
+ const parent = await mkdtemp(join(tmpdir(), "cca-test-"));
21
+ cleanups.push(parent);
22
+ const previousCwd = process.cwd();
23
+ process.chdir(parent);
24
+ try {
25
+ const code = await runCreate([name]);
26
+ expect(code).toBe(0);
27
+ } finally {
28
+ process.chdir(previousCwd);
29
+ }
30
+ return join(parent, name);
31
+ }
32
+
33
+ describe("args", () => {
34
+ it("reads flag values and ignores flag-shaped values", () => {
35
+ expect(argValue(["--dir", "apps/x"], "--dir")).toBe("apps/x");
36
+ expect(argValue(["--dir", "--other"], "--dir")).toBeNull();
37
+ expect(argValue([], "--dir")).toBeNull();
38
+ expect(hasFlag(["--watch"], "--watch")).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe("runCreate", () => {
43
+ it("scaffolds with placeholders replaced and gitignore restored", async () => {
44
+ const dir = await scaffoldTemp("team-crm");
45
+ const config = await readFile(join(dir, "app.config.ts"), "utf8");
46
+ expect(config).toContain('key: "team-crm"');
47
+ expect(config).toContain('"team-crm.note"');
48
+ expect(config).not.toContain("__APP_KEY__");
49
+ const packageJson = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) as {
50
+ name: string;
51
+ };
52
+ expect(packageJson.name).toBe("team-crm");
53
+ await expect(readFile(join(dir, ".gitignore"), "utf8")).resolves.toContain("node_modules");
54
+ });
55
+
56
+ it("refuses to scaffold over a non-empty directory", async () => {
57
+ const dir = await scaffoldTemp("first");
58
+ const previousCwd = process.cwd();
59
+ process.chdir(resolve(dir, ".."));
60
+ try {
61
+ await expect(runCreate(["first"])).resolves.toBe(1);
62
+ } finally {
63
+ process.chdir(previousCwd);
64
+ }
65
+ });
66
+
67
+ it("produces an app config that builds against the real SDK", async () => {
68
+ const dir = await scaffoldTemp("smoke-app");
69
+ const sdkRoot = resolve(__dirname, "..", "..", "sdk");
70
+ const sdkRequire = createRequire(join(sdkRoot, "package.json"));
71
+ const result = await build({
72
+ entryPoints: [join(dir, "app.config.ts")],
73
+ bundle: true,
74
+ write: false,
75
+ format: "esm",
76
+ platform: "neutral",
77
+ target: "es2022",
78
+ logLevel: "silent",
79
+ absWorkingDir: resolve(__dirname, ".."),
80
+ alias: {
81
+ "@colineapp/sdk/v2": join(sdkRoot, "src", "v2.ts"),
82
+ "zod/v4": sdkRequire.resolve("zod/v4"),
83
+ },
84
+ mainFields: ["module", "main"],
85
+ conditions: ["import", "default"],
86
+ });
87
+ const bundleText = result.outputFiles?.[0]?.text;
88
+ expect(bundleText).toBeTruthy();
89
+
90
+ const out = await mkdtemp(join(tmpdir(), "cca-bundle-"));
91
+ cleanups.push(out);
92
+ const bundlePath = join(out, "app.mjs");
93
+ await writeFile(bundlePath, bundleText ?? "", "utf8");
94
+ const imported = (await import(pathToFileURL(bundlePath).href)) as {
95
+ default: { manifest: { key: string; tools: Array<{ name: string }> } };
96
+ };
97
+ expect(imported.default.manifest.key).toBe("smoke-app");
98
+ expect(imported.default.manifest.tools[0]?.name).toBe("smoke-app.create_note");
99
+ });
100
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { runCreate as create } from "./scaffold";
2
+ import { runDev } from "./dev";
3
+ import { runPush } from "./push";
4
+
5
+ const COLINE_APP_HELP = `coline-app — push and iterate on Coline Apps.
6
+
7
+ Usage:
8
+ coline-app push [options] Build and upload a new app version
9
+ coline-app dev [options] Watch the project and re-push on change
10
+
11
+ Options:
12
+ --dir <path> App project directory (default: current directory)
13
+ --base-url <url> Coline instance URL (default: $COLINE_BASE_URL or https://coline.app)
14
+ --api-key <key> Workspace API key with the apps.write scope (default: $COLINE_API_KEY)
15
+ --version <semver> Version to publish (default: package.json version, then a dev stamp)
16
+
17
+ The API key is created in Workspace Settings → API. Pushed versions land
18
+ in your developer console in draft review state; pin them from there
19
+ while iterating.`;
20
+
21
+ export async function runCreate(argv: string[]): Promise<number> {
22
+ return create(argv);
23
+ }
24
+
25
+ export async function runColineApp(argv: string[]): Promise<number> {
26
+ const [command, ...rest] = argv;
27
+ switch (command) {
28
+ case "push":
29
+ return runPush(rest);
30
+ case "dev":
31
+ return runDev(rest);
32
+ case "help":
33
+ case "--help":
34
+ case undefined:
35
+ console.log(COLINE_APP_HELP);
36
+ return command === undefined ? 1 : 0;
37
+ default:
38
+ console.error(`Unknown command "${command}".\n\n${COLINE_APP_HELP}`);
39
+ return 1;
40
+ }
41
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { watch } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pushAppVersion, resolvePushConfig } from "./push";
4
+
5
+ const DEBOUNCE_MS = 400;
6
+
7
+ export async function runDev(argv: string[]): Promise<number> {
8
+ const config = resolvePushConfig(argv);
9
+ console.log(`Watching ${config.dir} — every change pushes a new dev version.`);
10
+
11
+ let timer: ReturnType<typeof setTimeout> | null = null;
12
+ let pushing = false;
13
+ let dirty = false;
14
+
15
+ async function pushOnce(): Promise<void> {
16
+ if (pushing) {
17
+ dirty = true;
18
+ return;
19
+ }
20
+ pushing = true;
21
+ try {
22
+ const result = await pushAppVersion({
23
+ ...config,
24
+ version: `0.0.${Math.floor(Date.now() / 1000)}`,
25
+ });
26
+ console.log(`[${new Date().toLocaleTimeString()}] pushed ${result.appKey}@${result.version}`);
27
+ } catch (error) {
28
+ console.error(error instanceof Error ? error.message : String(error));
29
+ } finally {
30
+ pushing = false;
31
+ if (dirty) {
32
+ dirty = false;
33
+ void pushOnce();
34
+ }
35
+ }
36
+ }
37
+
38
+ const watcher = watch(config.dir, { recursive: true }, (_event, fileName) => {
39
+ if (!fileName) {
40
+ return;
41
+ }
42
+ if (
43
+ fileName.includes("node_modules") ||
44
+ fileName.includes(".git") ||
45
+ fileName.includes(join(".cache", ""))
46
+ ) {
47
+ return;
48
+ }
49
+ if (timer) {
50
+ clearTimeout(timer);
51
+ }
52
+ timer = setTimeout(() => void pushOnce(), DEBOUNCE_MS);
53
+ });
54
+
55
+ await pushOnce();
56
+
57
+ return new Promise<number>((resolvePromise) => {
58
+ process.on("SIGINT", () => {
59
+ watcher.close();
60
+ resolvePromise(0);
61
+ });
62
+ });
63
+ }
package/src/push.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { build } from "esbuild";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { tmpdir } from "node:os";
6
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
7
+ import { argValue } from "./args";
8
+
9
+ interface ManifestSummary {
10
+ key: string;
11
+ name: string;
12
+ surfaces: Record<string, { tier?: string } | undefined>;
13
+ files: Array<{ surfaces?: Record<string, { tier?: string } | undefined> }>;
14
+ }
15
+
16
+ export interface PushConfig {
17
+ dir: string;
18
+ baseUrl: string;
19
+ apiKey: string;
20
+ version: string | null;
21
+ }
22
+
23
+ export interface PushResult {
24
+ appKey: string;
25
+ version: string;
26
+ appId: string;
27
+ versionId: string;
28
+ }
29
+
30
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".css"]);
31
+ const IGNORED_DIRECTORIES = new Set(["node_modules", "dist", ".git", ".cache", ".coline"]);
32
+
33
+ export function resolvePushConfig(argv: readonly string[]): PushConfig {
34
+ const apiKey = argValue(argv, "--api-key") ?? process.env.COLINE_API_KEY ?? null;
35
+ if (!apiKey) {
36
+ throw new Error(
37
+ "An API key is required. Pass --api-key or set COLINE_API_KEY. " +
38
+ "Create one with the apps.write scope in Workspace Settings → API.",
39
+ );
40
+ }
41
+ return {
42
+ dir: resolve(process.cwd(), argValue(argv, "--dir") ?? "."),
43
+ baseUrl: (
44
+ argValue(argv, "--base-url") ??
45
+ process.env.COLINE_BASE_URL ??
46
+ "https://coline.app"
47
+ ).replace(/\/+$/, ""),
48
+ apiKey,
49
+ version: argValue(argv, "--version"),
50
+ };
51
+ }
52
+
53
+ async function buildLogicBundle(appDir: string): Promise<string> {
54
+ const result = await build({
55
+ entryPoints: [join(appDir, "app.config.ts")],
56
+ bundle: true,
57
+ write: false,
58
+ format: "esm",
59
+ platform: "browser",
60
+ target: "es2022",
61
+ minify: true,
62
+ absWorkingDir: appDir,
63
+ define: { "process.env.NODE_ENV": '"production"' },
64
+ logLevel: "silent",
65
+ });
66
+ const output = result.outputFiles?.[0]?.text;
67
+ if (!output) {
68
+ throw new Error("Logic bundle build produced no output.");
69
+ }
70
+ return output;
71
+ }
72
+
73
+ /**
74
+ * Extract the serializable manifest by importing the app's own bundle
75
+ * (plan §6.2 two-artifact model). The app's SDK version travels inside
76
+ * the bundle, so the CLI never needs to agree with it.
77
+ */
78
+ async function extractManifest(logicBundleJs: string): Promise<ManifestSummary> {
79
+ const dir = await mkdtemp(join(tmpdir(), "coline-app-"));
80
+ const bundlePath = join(dir, "app.config.mjs");
81
+ try {
82
+ await writeFile(bundlePath, logicBundleJs, "utf8");
83
+ const imported = (await import(pathToFileURL(bundlePath).href)) as {
84
+ default?: { manifest?: unknown };
85
+ };
86
+ const manifest = imported.default?.manifest;
87
+ if (!manifest || typeof manifest !== "object") {
88
+ throw new Error(
89
+ "app.config.ts must default-export defineApp(...); no manifest was found.",
90
+ );
91
+ }
92
+ return JSON.parse(JSON.stringify(manifest)) as ManifestSummary;
93
+ } finally {
94
+ await rm(dir, { recursive: true, force: true });
95
+ }
96
+ }
97
+
98
+ function manifestHasReactSurfaces(manifest: ManifestSummary): boolean {
99
+ const tiers = [
100
+ ...Object.values(manifest.surfaces ?? {}).map((surface) => surface?.tier),
101
+ ...(manifest.files ?? []).flatMap((file) =>
102
+ Object.values(file.surfaces ?? {}).map((surface) => surface?.tier),
103
+ ),
104
+ ];
105
+ return tiers.includes("react");
106
+ }
107
+
108
+ async function collectClientSource(
109
+ appDir: string,
110
+ ): Promise<{ entry: string; files: Record<string, string> } | null> {
111
+ const entry = "main.tsx";
112
+ const entryExists = await stat(join(appDir, entry)).then(
113
+ (item) => item.isFile(),
114
+ () => false,
115
+ );
116
+ if (!entryExists) {
117
+ return null;
118
+ }
119
+
120
+ const files: Record<string, string> = {};
121
+ async function walk(dir: string): Promise<void> {
122
+ for (const item of await readdir(dir, { withFileTypes: true })) {
123
+ if (item.isDirectory()) {
124
+ if (!IGNORED_DIRECTORIES.has(item.name)) {
125
+ await walk(join(dir, item.name));
126
+ }
127
+ continue;
128
+ }
129
+ const extension = item.name.slice(item.name.lastIndexOf("."));
130
+ if (!SOURCE_EXTENSIONS.has(extension) || item.name.endsWith(".test.ts")) {
131
+ continue;
132
+ }
133
+ const filePath = join(dir, item.name);
134
+ files[relative(appDir, filePath)] = await readFile(filePath, "utf8");
135
+ }
136
+ }
137
+ await walk(appDir);
138
+ return { entry, files };
139
+ }
140
+
141
+ async function resolveVersion(appDir: string, explicit: string | null): Promise<string> {
142
+ if (explicit) {
143
+ return explicit;
144
+ }
145
+ const packageJson = await readFile(join(appDir, "package.json"), "utf8").catch(() => null);
146
+ if (packageJson) {
147
+ const parsed = JSON.parse(packageJson) as { version?: string };
148
+ if (parsed.version && parsed.version !== "0.0.0") {
149
+ return parsed.version;
150
+ }
151
+ }
152
+ return `0.0.${Math.floor(Date.now() / 1000)}`;
153
+ }
154
+
155
+ export async function pushAppVersion(config: PushConfig): Promise<PushResult> {
156
+ const logicBundleJs = await buildLogicBundle(config.dir);
157
+ const manifest = await extractManifest(logicBundleJs);
158
+ const version = await resolveVersion(config.dir, config.version);
159
+ const clientSource = manifestHasReactSurfaces(manifest)
160
+ ? await collectClientSource(config.dir)
161
+ : null;
162
+
163
+ if (manifestHasReactSurfaces(manifest) && !clientSource) {
164
+ throw new Error(
165
+ "The manifest declares react surfaces but no main.tsx client entry was found.",
166
+ );
167
+ }
168
+
169
+ const response = await fetch(
170
+ `${config.baseUrl}/api/v1/apps/${encodeURIComponent(manifest.key)}/versions`,
171
+ {
172
+ method: "POST",
173
+ headers: {
174
+ "content-type": "application/json",
175
+ authorization: `Bearer ${config.apiKey}`,
176
+ },
177
+ body: JSON.stringify({
178
+ protocolVersion: 2,
179
+ version,
180
+ manifest,
181
+ logicBundleJs,
182
+ clientSource,
183
+ }),
184
+ },
185
+ );
186
+
187
+ const payload = (await response.json().catch(() => null)) as {
188
+ data?: { appId: string; versionId: string };
189
+ error?: { code: string; message: string };
190
+ } | null;
191
+
192
+ if (!response.ok || !payload?.data) {
193
+ const detail = payload?.error
194
+ ? `${payload.error.code}: ${payload.error.message}`
195
+ : `HTTP ${response.status}`;
196
+ throw new Error(`Push failed — ${detail}`);
197
+ }
198
+
199
+ return {
200
+ appKey: manifest.key,
201
+ version,
202
+ appId: payload.data.appId,
203
+ versionId: payload.data.versionId,
204
+ };
205
+ }
206
+
207
+ export async function runPush(argv: string[]): Promise<number> {
208
+ const config = resolvePushConfig(argv);
209
+ const result = await pushAppVersion(config);
210
+ console.log(
211
+ `Pushed ${result.appKey}@${result.version} (version ${result.versionId}).\n` +
212
+ "The version is in draft review state — pin or submit it from the developer console.",
213
+ );
214
+ return 0;
215
+ }
@@ -0,0 +1,88 @@
1
+ import { cp, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const TEMPLATE_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "templates");
6
+
7
+ function toAppKey(input: string): string {
8
+ const key = input
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/^-+|-+$/g, "");
12
+ if (!/^[a-z][a-z0-9-]{1,63}$/.test(key)) {
13
+ throw new Error(`Cannot derive a valid app key from "${input}".`);
14
+ }
15
+ return key;
16
+ }
17
+
18
+ function toAppName(key: string): string {
19
+ return key
20
+ .split("-")
21
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
22
+ .join(" ");
23
+ }
24
+
25
+ async function walkFiles(dir: string, prefix = ""): Promise<string[]> {
26
+ const entries = await readdir(dir, { withFileTypes: true });
27
+ const files: string[] = [];
28
+ for (const entry of entries) {
29
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
30
+ if (entry.isDirectory()) {
31
+ files.push(...(await walkFiles(join(dir, entry.name), relative)));
32
+ } else {
33
+ files.push(relative);
34
+ }
35
+ }
36
+ return files;
37
+ }
38
+
39
+ export async function runCreate(argv: string[]): Promise<number> {
40
+ const target = argv.find((arg) => !arg.startsWith("--"));
41
+ if (!target) {
42
+ console.log("Usage: create-coline-app <directory>");
43
+ return 1;
44
+ }
45
+
46
+ const targetDir = resolve(process.cwd(), target);
47
+ const appKey = toAppKey(target.split("/").pop() ?? target);
48
+ const appName = toAppName(appKey);
49
+ const templateDir = join(TEMPLATE_ROOT, "backendless");
50
+
51
+ const existing = await readdir(targetDir).catch(() => null);
52
+ if (existing && existing.length > 0) {
53
+ console.error(`Directory "${target}" already exists and is not empty.`);
54
+ return 1;
55
+ }
56
+
57
+ await mkdir(targetDir, { recursive: true });
58
+ await cp(templateDir, targetDir, { recursive: true });
59
+
60
+ // npm strips .gitignore from published packages; templates ship gitignore.
61
+ const shippedGitignore = join(targetDir, "gitignore");
62
+ await rename(shippedGitignore, join(targetDir, ".gitignore")).catch(() => undefined);
63
+
64
+ for (const relative of await walkFiles(targetDir)) {
65
+ const filePath = join(targetDir, relative);
66
+ const content = await readFile(filePath, "utf8");
67
+ if (content.includes("__APP_KEY__") || content.includes("__APP_NAME__")) {
68
+ await writeFile(
69
+ filePath,
70
+ content.replaceAll("__APP_KEY__", appKey).replaceAll("__APP_NAME__", appName),
71
+ "utf8",
72
+ );
73
+ }
74
+ }
75
+
76
+ console.log(`Created ${appName} (${appKey}) in ${target}.
77
+
78
+ Next steps:
79
+ cd ${target}
80
+ npm install
81
+ npm test
82
+
83
+ Then create a workspace API key with the apps.write scope in
84
+ Workspace Settings → API, and push your first version:
85
+ export COLINE_API_KEY=col_ws_...
86
+ npx coline-app push`);
87
+ return 0;
88
+ }
@@ -0,0 +1,38 @@
1
+ # __APP_NAME__
2
+
3
+ A backendless Coline App. The logic in `app.config.ts` runs on Coline's
4
+ hosted runtime — no servers to deploy.
5
+
6
+ ## Develop
7
+
8
+ ```sh
9
+ npm install
10
+ npm test # runs against the in-memory test workspace
11
+ ```
12
+
13
+ ## Push to Coline
14
+
15
+ Create a workspace API key with the `apps.write` scope in
16
+ **Workspace Settings → API**, then:
17
+
18
+ ```sh
19
+ export COLINE_API_KEY=col_ws_...
20
+ npm run push
21
+ ```
22
+
23
+ Versions land in your developer console in draft state. Use
24
+ `npm run dev` to watch the project and push on every change.
25
+
26
+ ## Grow the app
27
+
28
+ - Add Kairo tools with `defineTool` — declare the honest `effect`
29
+ (`read` / `write` / `destructive` / `external`); the runtime enforces
30
+ it as a capability ceiling.
31
+ - Add file types with `defineFileType` — tree previews render everywhere
32
+ Coline shows files, including mobile and Kairo chat.
33
+ - Add a React editor by setting a file surface to
34
+ `{ tier: "react", entry: "main.tsx" }` and creating `main.tsx`.
35
+ - Store app-private data with `coline.storage.kv` and
36
+ `coline.storage.collection(name)`.
37
+ - Call external APIs with `coline.net.fetch` after declaring
38
+ `network.external` hosts in the manifest.
@@ -0,0 +1,91 @@
1
+ import { z } from "zod/v4";
2
+ import { actions, defineApp, defineFileType, defineTool, ui } from "@colineapp/sdk/v2";
3
+
4
+ // __APP_NAME__ — a backendless Coline App. Everything here runs on
5
+ // Coline's hosted runtime: no servers, no deploys. `coline-app push`
6
+ // uploads this file as the logic bundle and main.tsx as client source.
7
+
8
+ const noteFileType = defineFileType({
9
+ metadata: {
10
+ typeKey: "__APP_KEY__.note",
11
+ name: "__APP_NAME__ Note",
12
+ indexable: true,
13
+ surfaces: {
14
+ preview: { tier: "tree" },
15
+ inline: { tier: "tree" },
16
+ },
17
+ },
18
+ runtime: {
19
+ renderPreview: (context) =>
20
+ ui.stack([
21
+ ui.heading(context.file.name),
22
+ ui.text(String(context.document.body ?? "")),
23
+ ]),
24
+ renderInline: (context) => ui.text(context.file.name),
25
+ index: {
26
+ title: (document) => String(document.title ?? "Untitled note"),
27
+ body: (document) => String(document.body ?? ""),
28
+ },
29
+ },
30
+ });
31
+
32
+ const createNote = defineTool({
33
+ name: "__APP_KEY__.create_note",
34
+ description: "Create a note with a title and body.",
35
+ input: z.object({
36
+ title: z.string().min(1),
37
+ body: z.string().default(""),
38
+ }),
39
+ effect: "write",
40
+ execute: async (input, context) => {
41
+ const file = await context.coline.files.create({
42
+ typeKey: "__APP_KEY__.note",
43
+ name: input.title,
44
+ document: { body: input.body },
45
+ });
46
+ return {
47
+ output: { fileId: file.fileId },
48
+ card: ui.stack([
49
+ ui.text(`Created “${input.title}”.`),
50
+ ui.reference({ kind: "file", id: file.fileId }),
51
+ ]),
52
+ };
53
+ },
54
+ });
55
+
56
+ export default defineApp({
57
+ key: "__APP_KEY__",
58
+ name: "__APP_NAME__",
59
+ description: "A starter Coline App.",
60
+ permissions: ["files.read", "files.write", "storage.app", "ai.tools"],
61
+ hosting: { default: "coline" },
62
+ surfaces: {
63
+ home: { tier: "tree" },
64
+ },
65
+ files: [noteFileType],
66
+ tools: [createNote],
67
+ handlers: {
68
+ renderHome: async (context) => {
69
+ const { files } = await context.coline.files.list({
70
+ typeKey: "__APP_KEY__.note",
71
+ });
72
+ return ui.stack([
73
+ ui.heading("__APP_NAME__"),
74
+ files.length === 0
75
+ ? ui.emptyState({
76
+ title: "No notes yet",
77
+ description: "Ask Kairo to create one.",
78
+ })
79
+ : ui.stack(
80
+ files.map((file) =>
81
+ ui.fileCard({
82
+ title: file.name,
83
+ fileId: file.fileId,
84
+ action: actions.openFile(file.fileId),
85
+ }),
86
+ ),
87
+ ),
88
+ ]);
89
+ },
90
+ },
91
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestWorkspace } from "@colineapp/sdk/testing";
3
+ import app from "./app.config";
4
+
5
+ describe("__APP_NAME__", () => {
6
+ it("creates a note through the Kairo tool", async () => {
7
+ const workspace = createTestWorkspace(app);
8
+ const result = await workspace.invokeTool("__APP_KEY__.create_note", {
9
+ title: "Hello",
10
+ body: "First note.",
11
+ });
12
+ expect(result.card).not.toBeNull();
13
+ expect(workspace.files.byType("__APP_KEY__.note")).toHaveLength(1);
14
+ });
15
+
16
+ it("renders the home surface", async () => {
17
+ const workspace = createTestWorkspace(app);
18
+ await workspace.invokeTool("__APP_KEY__.create_note", { title: "Hello" });
19
+ const tree = await workspace.renderHome();
20
+ expect(JSON.stringify(tree)).toContain("Hello");
21
+ });
22
+ });
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ dist
3
+ .cache
4
+ .coline
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__APP_KEY__",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "typecheck": "tsc --noEmit",
9
+ "push": "coline-app push",
10
+ "dev": "coline-app dev"
11
+ },
12
+ "dependencies": {
13
+ "@colineapp/sdk": "^0.2.0",
14
+ "zod": "^4.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "create-coline-app": "^2.0.0",
18
+ "typescript": "^5.7.2",
19
+ "vitest": "^3.0.5"
20
+ }
21
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "jsx": "react-jsx"
12
+ },
13
+ "include": ["**/*.ts", "**/*.tsx"],
14
+ "exclude": ["node_modules"]
15
+ }