robodev 0.14.0 → 0.16.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/package.json +1 -1
- package/src/collect-files.test.ts +10 -1
- package/src/collect-files.ts +65 -2
- package/src/config.test.ts +70 -2
- package/src/config.ts +117 -14
- package/src/emit-starter.test.ts +20 -0
- package/src/frontend-build.test.ts +59 -0
- package/src/frontend-build.ts +212 -0
- package/src/index.ts +72 -22
- package/templates/auth-chat/README.md +4 -4
- package/templates/auth-chat/apps/fe/package.json +1 -1
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/space/README.md +4 -4
- package/templates/space/apps/fe/package.json +1 -1
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { test } from "node:test";
|
|
7
|
-
import { collectFiles } from "./collect-files.js";
|
|
7
|
+
import { collectFiles, mergeCollectedFiles } from "./collect-files.js";
|
|
8
8
|
|
|
9
9
|
test("collectFiles includes hosted sources and skips denied paths", async () => {
|
|
10
10
|
const root = await mkdtemp(join(tmpdir(), "rd-cli-"));
|
|
@@ -60,6 +60,15 @@ test("collectFiles skips nested apps/fe sources", async () => {
|
|
|
60
60
|
assert.ok(!paths.has("apps/fe/src/main.tsx"));
|
|
61
61
|
assert.ok(!paths.has("apps/fe/index.html"));
|
|
62
62
|
assert.ok(!paths.has("apps/fe/package.json"));
|
|
63
|
+
const merged = mergeCollectedFiles(files, [
|
|
64
|
+
{ path: "index.html", content: "<html>built</html>" },
|
|
65
|
+
{ path: "public/assets/app.js", content: "console.log(1)" },
|
|
66
|
+
]);
|
|
67
|
+
const mergedPaths = new Set(merged.map((file) => file.path));
|
|
68
|
+
assert.ok(mergedPaths.has("index.html"));
|
|
69
|
+
assert.ok(mergedPaths.has("public/assets/app.js"));
|
|
70
|
+
assert.ok(!mergedPaths.has("apps/fe/src/main.tsx"));
|
|
71
|
+
assert.equal(merged.find((file) => file.path === "index.html")?.content, "<html>built</html>");
|
|
63
72
|
} finally {
|
|
64
73
|
await rm(root, { recursive: true, force: true });
|
|
65
74
|
}
|
package/src/collect-files.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { join, relative } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
export const MAX_FILE_COUNT = 200;
|
|
5
5
|
export const MAX_FILE_BYTES = 1024 * 1024;
|
|
6
|
+
/** Hashed Vite JS from Tiny can exceed 1MB; keep API/source at 1MB. */
|
|
7
|
+
export const MAX_STATIC_FILE_BYTES = 4 * 1024 * 1024;
|
|
6
8
|
export const MAX_TREE_BYTES = 15 * 1024 * 1024;
|
|
7
9
|
export const MAX_PATH_LENGTH = 240;
|
|
8
10
|
export const MAX_PATH_DEPTH = 12;
|
|
@@ -112,6 +114,66 @@ export function shouldCollectPath(path: string): boolean {
|
|
|
112
114
|
return !isUnsafe(normalized) && !isDenied(normalized) && isAllowed(normalized);
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
export function isStaticCollectedPath(path: string): boolean {
|
|
118
|
+
const normalized = normalize(path);
|
|
119
|
+
if (normalized === "index.html") return true;
|
|
120
|
+
if (normalized.startsWith("public/")) return true;
|
|
121
|
+
if (!normalized.includes("/") && ROOT_STATIC_EXTENSIONS.has(extname(normalized))) return true;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function maxBytesForCollectedPath(path: string): number {
|
|
126
|
+
return isStaticCollectedPath(path) ? MAX_STATIC_FILE_BYTES : MAX_FILE_BYTES;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatMaxBytes(bytes: number): string {
|
|
130
|
+
return `${Math.round(bytes / (1024 * 1024))}MB`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function assertCollectedFile(file: CollectedFile): void {
|
|
134
|
+
const path = normalize(file.path);
|
|
135
|
+
if (
|
|
136
|
+
isUnsafe(path) ||
|
|
137
|
+
!shouldCollectPath(path) ||
|
|
138
|
+
path.length > MAX_PATH_LENGTH ||
|
|
139
|
+
path.split("/").length > MAX_PATH_DEPTH
|
|
140
|
+
) {
|
|
141
|
+
throw new Error(`Invalid file path: ${path}`);
|
|
142
|
+
}
|
|
143
|
+
const bytes = Buffer.byteLength(file.content, "utf8");
|
|
144
|
+
const max = maxBytesForCollectedPath(path);
|
|
145
|
+
if (bytes > max) {
|
|
146
|
+
throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function assertCollectedLimits(files: CollectedFile[]): void {
|
|
151
|
+
if (files.length > MAX_FILE_COUNT) {
|
|
152
|
+
throw new Error(`Deploy may include at most ${MAX_FILE_COUNT} files`);
|
|
153
|
+
}
|
|
154
|
+
let totalBytes = 0;
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
assertCollectedFile(file);
|
|
157
|
+
totalBytes += Buffer.byteLength(file.content, "utf8");
|
|
158
|
+
if (totalBytes > MAX_TREE_BYTES) {
|
|
159
|
+
throw new Error("Deploy tree must be under 15MB");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function mergeCollectedFiles(
|
|
165
|
+
base: CollectedFile[],
|
|
166
|
+
extra: CollectedFile[],
|
|
167
|
+
): CollectedFile[] {
|
|
168
|
+
const map = new Map(base.map((file) => [normalize(file.path), file]));
|
|
169
|
+
for (const file of extra) {
|
|
170
|
+
map.set(normalize(file.path), file);
|
|
171
|
+
}
|
|
172
|
+
const files = [...map.values()];
|
|
173
|
+
assertCollectedLimits(files);
|
|
174
|
+
return files;
|
|
175
|
+
}
|
|
176
|
+
|
|
115
177
|
export async function collectFiles(root: string): Promise<CollectedFile[]> {
|
|
116
178
|
try {
|
|
117
179
|
await stat(join(root, "database.ts"));
|
|
@@ -144,8 +206,9 @@ export async function collectFiles(root: string): Promise<CollectedFile[]> {
|
|
|
144
206
|
}
|
|
145
207
|
const content = await readFile(abs, "utf8");
|
|
146
208
|
const bytes = Buffer.byteLength(content, "utf8");
|
|
147
|
-
|
|
148
|
-
|
|
209
|
+
const max = maxBytesForCollectedPath(path);
|
|
210
|
+
if (bytes > max) {
|
|
211
|
+
throw new Error(`File must be under ${formatMaxBytes(max)}: ${path}`);
|
|
149
212
|
}
|
|
150
213
|
totalBytes += bytes;
|
|
151
214
|
if (totalBytes > MAX_TREE_BYTES) {
|
package/src/config.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
|
-
import { writeViteApiUrl } from "./config.js";
|
|
6
|
+
import { defaultFrontendConfig, readLink, writeLink, writeViteApiUrl } from "./config.js";
|
|
7
7
|
|
|
8
8
|
test("writeViteApiUrl sets Vite and Povio public API URLs", async () => {
|
|
9
9
|
const root = await mkdtemp(join(tmpdir(), "rd-env-"));
|
|
@@ -18,8 +18,76 @@ test("writeViteApiUrl sets Vite and Povio public API URLs", async () => {
|
|
|
18
18
|
assert.match(env, /^VITE_API_URL=https:\/\/prj\.example\.test$/m);
|
|
19
19
|
assert.match(env, /^VITE_PUBLIC_API_URL=https:\/\/prj\.example\.test$/m);
|
|
20
20
|
assert.match(env, /^APP_PUBLIC_API_URL=https:\/\/prj\.example\.test$/m);
|
|
21
|
+
const template = await readFile(join(root, ".config/local.spa.template.yml"), "utf8");
|
|
22
|
+
assert.match(
|
|
23
|
+
template,
|
|
24
|
+
/APP_PUBLIC_API_URL: &APP_PUBLIC_API_URL "https:\/\/prj\.example\.test"/,
|
|
25
|
+
);
|
|
21
26
|
const spa = await readFile(join(root, ".config/local.spa.yml"), "utf8");
|
|
22
|
-
assert.match(spa,
|
|
27
|
+
assert.match(spa, /^\s*configs\s*:/m);
|
|
28
|
+
assert.match(spa, /templateModule:\s*spa\.template/);
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("writeLink merges frontend and unknown keys", async () => {
|
|
35
|
+
const root = await mkdtemp(join(tmpdir(), "rd-link-"));
|
|
36
|
+
try {
|
|
37
|
+
await writeFile(
|
|
38
|
+
join(root, ".robodev"),
|
|
39
|
+
`${JSON.stringify(
|
|
40
|
+
{
|
|
41
|
+
projectId: "prj-old",
|
|
42
|
+
apiUrl: "https://old.example",
|
|
43
|
+
frontend: defaultFrontendConfig(),
|
|
44
|
+
extra: { keep: true },
|
|
45
|
+
},
|
|
46
|
+
null,
|
|
47
|
+
2,
|
|
48
|
+
)}\n`,
|
|
49
|
+
);
|
|
50
|
+
await writeLink(
|
|
51
|
+
{
|
|
52
|
+
projectId: "prj-new",
|
|
53
|
+
apiUrl: "https://new.example",
|
|
54
|
+
projectApiUrl: "https://prj-new.example",
|
|
55
|
+
},
|
|
56
|
+
root,
|
|
57
|
+
);
|
|
58
|
+
const raw = JSON.parse(await readFile(join(root, ".robodev"), "utf8")) as {
|
|
59
|
+
projectId: string;
|
|
60
|
+
apiUrl: string;
|
|
61
|
+
projectApiUrl: string;
|
|
62
|
+
frontend: { root: string; script: string; outDir: string };
|
|
63
|
+
extra: { keep: boolean };
|
|
64
|
+
};
|
|
65
|
+
assert.equal(raw.projectId, "prj-new");
|
|
66
|
+
assert.equal(raw.apiUrl, "https://new.example");
|
|
67
|
+
assert.equal(raw.projectApiUrl, "https://prj-new.example");
|
|
68
|
+
assert.deepEqual(raw.frontend, defaultFrontendConfig());
|
|
69
|
+
assert.deepEqual(raw.extra, { keep: true });
|
|
70
|
+
const link = await readLink(root);
|
|
71
|
+
assert.deepEqual(link?.frontend, defaultFrontendConfig());
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(root, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("writeLink preserves frontend false opt-out", async () => {
|
|
78
|
+
const root = await mkdtemp(join(tmpdir(), "rd-link-off-"));
|
|
79
|
+
try {
|
|
80
|
+
await writeFile(
|
|
81
|
+
join(root, ".robodev"),
|
|
82
|
+
`${JSON.stringify({ projectId: "prj-1", apiUrl: "https://a.example", frontend: false }, null, 2)}\n`,
|
|
83
|
+
);
|
|
84
|
+
await writeLink({ projectId: "prj-1", apiUrl: "https://b.example" }, root);
|
|
85
|
+
const raw = JSON.parse(await readFile(join(root, ".robodev"), "utf8")) as {
|
|
86
|
+
frontend: unknown;
|
|
87
|
+
};
|
|
88
|
+
assert.equal(raw.frontend, false);
|
|
89
|
+
const link = await readLink(root);
|
|
90
|
+
assert.equal(link?.frontend, false);
|
|
23
91
|
} finally {
|
|
24
92
|
await rm(root, { recursive: true, force: true });
|
|
25
93
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
|
|
@@ -7,14 +7,27 @@ export type Credentials = {
|
|
|
7
7
|
refreshToken: string;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
export type FrontendConfig = {
|
|
11
|
+
root: string;
|
|
12
|
+
script?: string;
|
|
13
|
+
outDir: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
10
16
|
export type ProjectLink = {
|
|
11
17
|
projectId: string;
|
|
12
18
|
apiUrl: string;
|
|
13
19
|
projectApiUrl?: string;
|
|
20
|
+
frontend?: FrontendConfig | false;
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
const PROD_URL = "https://robodev.povio.dev";
|
|
17
24
|
|
|
25
|
+
const SPA_MANIFEST = `configs:
|
|
26
|
+
values:
|
|
27
|
+
- name: "@"
|
|
28
|
+
templateModule: spa.template
|
|
29
|
+
`;
|
|
30
|
+
|
|
18
31
|
/** Control-plane API. Override with `STARBASE_URL` for local Starbase. */
|
|
19
32
|
export function starbaseUrl(): string {
|
|
20
33
|
return (process.env.STARBASE_URL ?? PROD_URL).replace(/\/$/, "");
|
|
@@ -35,6 +48,37 @@ export function linkPath(cwd = process.cwd()): string {
|
|
|
35
48
|
return join(cwd, ".robodev");
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
export function defaultFrontendConfig(): FrontendConfig {
|
|
52
|
+
return { root: "apps/fe", script: "build", outDir: "dist" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isFrontendConfig(value: unknown): value is FrontendConfig {
|
|
56
|
+
return (
|
|
57
|
+
typeof value === "object" &&
|
|
58
|
+
value !== null &&
|
|
59
|
+
!Array.isArray(value) &&
|
|
60
|
+
typeof (value as FrontendConfig).root === "string" &&
|
|
61
|
+
typeof (value as FrontendConfig).outDir === "string"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeFrontendConfig(frontend: FrontendConfig): Required<FrontendConfig> {
|
|
66
|
+
const script = frontend.script?.trim();
|
|
67
|
+
return {
|
|
68
|
+
root: frontend.root,
|
|
69
|
+
script: script && script.length > 0 ? script : "build",
|
|
70
|
+
outDir: frontend.outDir,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function hasAppsFePackageJson(root: string): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
return (await stat(join(root, "apps/fe/package.json"))).isFile();
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
38
82
|
export async function readCredentials(): Promise<Credentials | null> {
|
|
39
83
|
try {
|
|
40
84
|
return JSON.parse(await readFile(credentialsPath(), "utf8")) as Credentials;
|
|
@@ -53,16 +97,50 @@ export async function clearCredentials(): Promise<void> {
|
|
|
53
97
|
await rm(credentialsPath(), { force: true });
|
|
54
98
|
}
|
|
55
99
|
|
|
56
|
-
export async function
|
|
100
|
+
export async function readLinkJson(cwd = process.cwd()): Promise<Record<string, unknown> | null> {
|
|
57
101
|
try {
|
|
58
|
-
|
|
102
|
+
const parsed = JSON.parse(await readFile(linkPath(cwd), "utf8")) as unknown;
|
|
103
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
104
|
+
return parsed as Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
59
107
|
} catch {
|
|
60
108
|
return null;
|
|
61
109
|
}
|
|
62
110
|
}
|
|
63
111
|
|
|
112
|
+
export async function readLink(cwd = process.cwd()): Promise<ProjectLink | null> {
|
|
113
|
+
const raw = await readLinkJson(cwd);
|
|
114
|
+
if (!raw || typeof raw.projectId !== "string" || typeof raw.apiUrl !== "string") {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const link: ProjectLink = {
|
|
118
|
+
projectId: raw.projectId,
|
|
119
|
+
apiUrl: raw.apiUrl,
|
|
120
|
+
};
|
|
121
|
+
if (typeof raw.projectApiUrl === "string") {
|
|
122
|
+
link.projectApiUrl = raw.projectApiUrl;
|
|
123
|
+
}
|
|
124
|
+
if (raw.frontend === false || isFrontendConfig(raw.frontend)) {
|
|
125
|
+
link.frontend = raw.frontend;
|
|
126
|
+
}
|
|
127
|
+
return link;
|
|
128
|
+
}
|
|
129
|
+
|
|
64
130
|
export async function writeLink(link: ProjectLink, cwd = process.cwd()): Promise<void> {
|
|
65
|
-
await
|
|
131
|
+
const existing = (await readLinkJson(cwd)) ?? {};
|
|
132
|
+
const next: Record<string, unknown> = {
|
|
133
|
+
...existing,
|
|
134
|
+
projectId: link.projectId,
|
|
135
|
+
apiUrl: link.apiUrl,
|
|
136
|
+
};
|
|
137
|
+
if (link.projectApiUrl !== undefined) {
|
|
138
|
+
next.projectApiUrl = link.projectApiUrl;
|
|
139
|
+
}
|
|
140
|
+
if ("frontend" in link) {
|
|
141
|
+
next.frontend = link.frontend;
|
|
142
|
+
}
|
|
143
|
+
await writeFile(linkPath(cwd), `${JSON.stringify(next, null, 2)}\n`);
|
|
66
144
|
}
|
|
67
145
|
|
|
68
146
|
const PROJECT_API_ENV_KEYS = ["VITE_API_URL", "VITE_PUBLIC_API_URL", "APP_PUBLIC_API_URL"] as const;
|
|
@@ -75,6 +153,17 @@ function upsertEnvKey(content: string, key: string, value: string): string {
|
|
|
75
153
|
return `${trimmed}${trimmed ? "\n" : ""}${line}\n`;
|
|
76
154
|
}
|
|
77
155
|
|
|
156
|
+
function isSpaManifest(content: string): boolean {
|
|
157
|
+
return /^\s*configs\s*:/m.test(content);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function rewriteApiUrlInSpaValues(source: string, projectApiUrl: string): string {
|
|
161
|
+
const quoted = JSON.stringify(projectApiUrl);
|
|
162
|
+
const next = source.replace(/^(APP_PUBLIC_API_URL:\s*&APP_PUBLIC_API_URL\s+).+$/m, `$1${quoted}`);
|
|
163
|
+
if (next !== source) return next;
|
|
164
|
+
return source.replace(/^(VITE_PUBLIC_API_URL:\s+)(?!\*).+$/m, `$1${quoted}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
78
167
|
/** Sets Vite / Povio API URLs so starter UIs talk to the deployed project host. */
|
|
79
168
|
export async function writeViteApiUrl(projectApiUrl: string, cwd = process.cwd()): Promise<void> {
|
|
80
169
|
const envPath = join(cwd, ".env");
|
|
@@ -95,18 +184,32 @@ async function rewriteLocalSpaConfig(projectApiUrl: string, cwd: string): Promis
|
|
|
95
184
|
const configDir = join(cwd, ".config");
|
|
96
185
|
const templatePath = join(configDir, "local.spa.template.yml");
|
|
97
186
|
const destPath = join(configDir, "local.spa.yml");
|
|
98
|
-
let
|
|
187
|
+
let template: string | null = null;
|
|
99
188
|
try {
|
|
100
|
-
|
|
189
|
+
template = await readFile(templatePath, "utf8");
|
|
101
190
|
} catch {
|
|
102
|
-
|
|
103
|
-
source = await readFile(destPath, "utf8");
|
|
104
|
-
} catch {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
191
|
+
template = null;
|
|
107
192
|
}
|
|
108
|
-
|
|
109
|
-
|
|
193
|
+
let dest: string | null = null;
|
|
194
|
+
try {
|
|
195
|
+
dest = await readFile(destPath, "utf8");
|
|
196
|
+
} catch {
|
|
197
|
+
dest = null;
|
|
198
|
+
}
|
|
199
|
+
if (!template && !dest) return;
|
|
200
|
+
|
|
110
201
|
await mkdir(configDir, { recursive: true });
|
|
111
|
-
|
|
202
|
+
if (template) {
|
|
203
|
+
const next = rewriteApiUrlInSpaValues(template, projectApiUrl);
|
|
204
|
+
if (next !== template) {
|
|
205
|
+
await writeFile(templatePath, next.endsWith("\n") ? next : `${next}\n`);
|
|
206
|
+
}
|
|
207
|
+
} else if (dest && !isSpaManifest(dest)) {
|
|
208
|
+
const next = rewriteApiUrlInSpaValues(dest, projectApiUrl);
|
|
209
|
+
await writeFile(templatePath, next.endsWith("\n") ? next : `${next}\n`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!dest || !isSpaManifest(dest)) {
|
|
213
|
+
await writeFile(destPath, SPA_MANIFEST);
|
|
214
|
+
}
|
|
112
215
|
}
|
package/src/emit-starter.test.ts
CHANGED
|
@@ -24,11 +24,13 @@ test("emitStarter rewrites root package.json name only", async () => {
|
|
|
24
24
|
name: string;
|
|
25
25
|
dependencies?: Record<string, string>;
|
|
26
26
|
devDependencies?: Record<string, string>;
|
|
27
|
+
scripts?: { build?: string };
|
|
27
28
|
};
|
|
28
29
|
assert.equal(rootPkg.name, "orbit-app");
|
|
29
30
|
assert.equal(fePkg.name, "fe");
|
|
30
31
|
assert.equal(fePkg.dependencies?.robodev, undefined);
|
|
31
32
|
assert.equal(fePkg.devDependencies?.robodev, undefined);
|
|
33
|
+
assert.match(fePkg.scripts?.build ?? "", /openapi:gen.*vite build/);
|
|
32
34
|
const overview = await readFile(join(dest, ".rulesync/rules/project-overview.md"), "utf8");
|
|
33
35
|
assert.match(overview, /^# Space starter AI rules/m);
|
|
34
36
|
const readme = await readFile(join(dest, "README.md"), "utf8");
|
|
@@ -38,6 +40,24 @@ test("emitStarter rewrites root package.json name only", async () => {
|
|
|
38
40
|
}
|
|
39
41
|
});
|
|
40
42
|
|
|
43
|
+
test("emitStarter auth-chat FE build runs openapi:gen before vite", async () => {
|
|
44
|
+
const dest = await mkdtemp(join(tmpdir(), "rd-emit-"));
|
|
45
|
+
try {
|
|
46
|
+
await emitStarter(join(cliRoot(), "..", "starters", "auth-chat"), dest, {
|
|
47
|
+
name: "Relay",
|
|
48
|
+
packageName: "relay-app",
|
|
49
|
+
title: "Auth chat",
|
|
50
|
+
versions,
|
|
51
|
+
});
|
|
52
|
+
const fePkg = JSON.parse(await readFile(join(dest, "apps/fe/package.json"), "utf8")) as {
|
|
53
|
+
scripts?: { build?: string };
|
|
54
|
+
};
|
|
55
|
+
assert.match(fePkg.scripts?.build ?? "", /openapi:gen.*vite build/);
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(dest, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
41
61
|
test("collectFiles for auth-chat starter is Tiny APIs without apps/fe", async () => {
|
|
42
62
|
const files = await collectFiles(join(cliRoot(), "..", "starters", "auth-chat"));
|
|
43
63
|
const paths = new Set(files.map((file) => file.path));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
mapFrontendOutDir,
|
|
8
|
+
resolveFrontendPaths,
|
|
9
|
+
sanitizeProjectRelative,
|
|
10
|
+
} from "./frontend-build.js";
|
|
11
|
+
|
|
12
|
+
test("sanitizeProjectRelative rejects parent and absolute paths", () => {
|
|
13
|
+
const root = resolve("/tmp/project");
|
|
14
|
+
assert.throws(() => sanitizeProjectRelative(root, "../fe", "root"), /Invalid frontend\.root/);
|
|
15
|
+
assert.throws(() => sanitizeProjectRelative(root, "/abs", "root"), /Invalid frontend\.root/);
|
|
16
|
+
assert.throws(
|
|
17
|
+
() => sanitizeProjectRelative(root, "apps/fe/../../etc", "root"),
|
|
18
|
+
/Invalid frontend\.root/,
|
|
19
|
+
);
|
|
20
|
+
assert.equal(sanitizeProjectRelative(root, "apps/fe", "root"), resolve(root, "apps/fe"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("resolveFrontendPaths keeps outDir inside the project", () => {
|
|
24
|
+
const root = resolve("/tmp/project");
|
|
25
|
+
const resolved = resolveFrontendPaths(root, { root: "apps/fe", script: "build", outDir: "dist" });
|
|
26
|
+
assert.equal(resolved.rootAbs, resolve(root, "apps/fe"));
|
|
27
|
+
assert.equal(resolved.outAbs, resolve(root, "apps/fe/dist"));
|
|
28
|
+
assert.throws(
|
|
29
|
+
() =>
|
|
30
|
+
resolveFrontendPaths(root, { root: "apps/fe", script: "build && rm -rf /", outDir: "dist" }),
|
|
31
|
+
/Invalid frontend\.script/,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("mapFrontendOutDir remaps text files and skips binaries", async () => {
|
|
36
|
+
const out = await mkdtemp(join(tmpdir(), "rd-out-"));
|
|
37
|
+
try {
|
|
38
|
+
await writeFile(join(out, "index.html"), "<html><body>app</body></html>");
|
|
39
|
+
await mkdir(join(out, "assets"), { recursive: true });
|
|
40
|
+
await writeFile(join(out, "assets/index-abc.js"), "console.log(1)");
|
|
41
|
+
await writeFile(join(out, "assets/index-abc.css"), "body{color:red}");
|
|
42
|
+
await writeFile(join(out, "assets/font.woff2"), "binary-font");
|
|
43
|
+
await writeFile(join(out, "icon.png"), "binary-png");
|
|
44
|
+
const { files, skippedBinaries } = await mapFrontendOutDir(out);
|
|
45
|
+
const paths = new Set(files.map((file) => file.path));
|
|
46
|
+
assert.ok(paths.has("index.html"));
|
|
47
|
+
assert.ok(paths.has("public/assets/index-abc.js"));
|
|
48
|
+
assert.ok(paths.has("public/assets/index-abc.css"));
|
|
49
|
+
assert.ok(!paths.has("public/assets/font.woff2"));
|
|
50
|
+
assert.ok(!paths.has("public/icon.png"));
|
|
51
|
+
assert.equal(skippedBinaries, true);
|
|
52
|
+
assert.equal(
|
|
53
|
+
files.find((file) => file.path === "index.html")?.content,
|
|
54
|
+
"<html><body>app</body></html>",
|
|
55
|
+
);
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(out, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { assertCollectedFile, type CollectedFile, shouldCollectPath } from "./collect-files.js";
|
|
6
|
+
import { isFrontendConfig, normalizeFrontendConfig, type FrontendConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export type FrontendBuildOptions = {
|
|
11
|
+
install: "always" | "if-needed";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function sanitizeProjectRelative(projectRoot: string, rel: string, label: string): string {
|
|
15
|
+
const raw = rel.trim();
|
|
16
|
+
const normalized = raw.replaceAll("\\", "/");
|
|
17
|
+
if (
|
|
18
|
+
!normalized ||
|
|
19
|
+
normalized.includes("..") ||
|
|
20
|
+
isAbsolute(raw) ||
|
|
21
|
+
normalized.startsWith("/") ||
|
|
22
|
+
/^[A-Za-z]:/.test(raw)
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(`Invalid frontend.${label}: ${rel}`);
|
|
25
|
+
}
|
|
26
|
+
const abs = resolve(projectRoot, normalized);
|
|
27
|
+
const relToRoot = relative(resolve(projectRoot), abs);
|
|
28
|
+
if (relToRoot.startsWith("..") || isAbsolute(relToRoot)) {
|
|
29
|
+
throw new Error(`frontend.${label} must stay inside the project: ${rel}`);
|
|
30
|
+
}
|
|
31
|
+
return abs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveFrontendPaths(
|
|
35
|
+
projectRoot: string,
|
|
36
|
+
frontend: FrontendConfig,
|
|
37
|
+
): { rootAbs: string; outAbs: string; config: Required<FrontendConfig> } {
|
|
38
|
+
const config = normalizeFrontendConfig(frontend);
|
|
39
|
+
const rootAbs = sanitizeProjectRelative(projectRoot, config.root, "root");
|
|
40
|
+
const outAbs = sanitizeProjectRelative(rootAbs, config.outDir, "outDir");
|
|
41
|
+
const outRelToProject = relative(resolve(projectRoot), outAbs);
|
|
42
|
+
if (outRelToProject.startsWith("..") || isAbsolute(outRelToProject)) {
|
|
43
|
+
throw new Error(`frontend.outDir must stay inside the project: ${config.outDir}`);
|
|
44
|
+
}
|
|
45
|
+
if (!/^[A-Za-z0-9:_-]+$/.test(config.script)) {
|
|
46
|
+
throw new Error(`Invalid frontend.script: ${config.script}`);
|
|
47
|
+
}
|
|
48
|
+
return { rootAbs, outAbs, config };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function commandAvailable(command: string): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
await execFileAsync(command, ["--version"]);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runCommand(
|
|
61
|
+
command: string,
|
|
62
|
+
args: string[],
|
|
63
|
+
cwd: string,
|
|
64
|
+
label: string,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const stdoutChunks: Buffer[] = [];
|
|
67
|
+
const stderrChunks: Buffer[] = [];
|
|
68
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
69
|
+
const child = spawn(command, args, {
|
|
70
|
+
cwd,
|
|
71
|
+
env: process.env,
|
|
72
|
+
shell: false,
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
});
|
|
75
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
76
|
+
stdoutChunks.push(chunk);
|
|
77
|
+
process.stdout.write(chunk);
|
|
78
|
+
});
|
|
79
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
80
|
+
stderrChunks.push(chunk);
|
|
81
|
+
process.stderr.write(chunk);
|
|
82
|
+
});
|
|
83
|
+
child.on("error", reject);
|
|
84
|
+
child.on("exit", (code) => {
|
|
85
|
+
if (code === 0) {
|
|
86
|
+
resolvePromise();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim();
|
|
90
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
91
|
+
const detail = [stderr, stdout].filter(Boolean).join("\n");
|
|
92
|
+
reject(
|
|
93
|
+
new Error(detail ? `${label} failed (${code})\n${detail}` : `${label} failed (${code})`),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function packageScripts(rootAbs: string): Promise<Record<string, string>> {
|
|
100
|
+
const pkgPath = join(rootAbs, "package.json");
|
|
101
|
+
try {
|
|
102
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as { scripts?: Record<string, string> };
|
|
103
|
+
return pkg.scripts ?? {};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
106
|
+
throw new Error(`Missing frontend package.json at ${pkgPath}`);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function ensureInstalled(
|
|
113
|
+
rootAbs: string,
|
|
114
|
+
mode: FrontendBuildOptions["install"],
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
if (mode === "if-needed") {
|
|
117
|
+
try {
|
|
118
|
+
if ((await stat(join(rootAbs, "node_modules"))).isDirectory()) return;
|
|
119
|
+
} catch {
|
|
120
|
+
// install
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const bun = await commandAvailable("bun");
|
|
124
|
+
const command = bun ? "bun" : "npm";
|
|
125
|
+
console.log(`Installing frontend packages with ${command}…`);
|
|
126
|
+
await runCommand(command, ["install"], rootAbs, `${command} install`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mappedDeployPath(relFromOutDir: string): string {
|
|
130
|
+
const rel = relFromOutDir.replaceAll("\\", "/");
|
|
131
|
+
if (rel === "index.html") return "index.html";
|
|
132
|
+
return `public/${rel}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function mapFrontendOutDir(outDirAbs: string): Promise<{
|
|
136
|
+
files: CollectedFile[];
|
|
137
|
+
skippedBinaries: boolean;
|
|
138
|
+
}> {
|
|
139
|
+
const files: CollectedFile[] = [];
|
|
140
|
+
let skippedBinaries = false;
|
|
141
|
+
|
|
142
|
+
async function walk(dir: string): Promise<void> {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
146
|
+
} catch {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const abs = join(dir, entry.name);
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
await walk(abs);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!entry.isFile()) continue;
|
|
156
|
+
const rel = relative(outDirAbs, abs).replaceAll("\\", "/");
|
|
157
|
+
const deployPath = mappedDeployPath(rel);
|
|
158
|
+
if (!shouldCollectPath(deployPath)) {
|
|
159
|
+
skippedBinaries = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const content = await readFile(abs, "utf8");
|
|
163
|
+
const file = { path: deployPath, content };
|
|
164
|
+
assertCollectedFile(file);
|
|
165
|
+
files.push(file);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await walk(outDirAbs);
|
|
170
|
+
return { files, skippedBinaries };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function buildAndCollectFrontend(
|
|
174
|
+
projectRoot: string,
|
|
175
|
+
frontend: FrontendConfig,
|
|
176
|
+
options: FrontendBuildOptions,
|
|
177
|
+
): Promise<CollectedFile[]> {
|
|
178
|
+
if (!isFrontendConfig(frontend)) {
|
|
179
|
+
throw new Error("Invalid frontend config in .robodev");
|
|
180
|
+
}
|
|
181
|
+
const { rootAbs, outAbs, config } = resolveFrontendPaths(projectRoot, frontend);
|
|
182
|
+
const scripts = await packageScripts(rootAbs);
|
|
183
|
+
if (!(config.script in scripts)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`package.json has no "${config.script}" script at ${join(rootAbs, "package.json")}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await ensureInstalled(rootAbs, options.install);
|
|
190
|
+
|
|
191
|
+
const bun = await commandAvailable("bun");
|
|
192
|
+
const command = bun ? "bun" : "npm";
|
|
193
|
+
console.log(`Building frontend (${command} run ${config.script})…`);
|
|
194
|
+
await runCommand(command, ["run", config.script], rootAbs, `${command} run ${config.script}`);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await stat(join(outAbs, "index.html"));
|
|
198
|
+
} catch {
|
|
199
|
+
throw new Error(`Frontend build did not produce ${join(outAbs, "index.html")}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { files, skippedBinaries } = await mapFrontendOutDir(outAbs);
|
|
203
|
+
if (!files.some((file) => file.path === "index.html")) {
|
|
204
|
+
throw new Error(`Frontend build did not produce ${join(outAbs, "index.html")}`);
|
|
205
|
+
}
|
|
206
|
+
if (skippedBinaries) {
|
|
207
|
+
console.log(
|
|
208
|
+
"Skipped binary frontend assets (fonts/images). Hosted UI may use system font fallback.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return files;
|
|
212
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,15 +9,21 @@ import { promisify } from "node:util";
|
|
|
9
9
|
import { ApiError, authed, publicRequest } from "./api.js";
|
|
10
10
|
import {
|
|
11
11
|
clearCredentials,
|
|
12
|
+
defaultFrontendConfig,
|
|
12
13
|
feUrl,
|
|
14
|
+
hasAppsFePackageJson,
|
|
15
|
+
isFrontendConfig,
|
|
16
|
+
type ProjectLink,
|
|
13
17
|
readLink,
|
|
18
|
+
readLinkJson,
|
|
14
19
|
starbaseUrl,
|
|
15
20
|
writeCredentials,
|
|
16
21
|
writeLink,
|
|
17
22
|
writeViteApiUrl,
|
|
18
23
|
} from "./config.js";
|
|
19
24
|
import { waitUntilLive } from "./wait-until-live.js";
|
|
20
|
-
import { collectFiles } from "./collect-files.js";
|
|
25
|
+
import { collectFiles, mergeCollectedFiles, type CollectedFile } from "./collect-files.js";
|
|
26
|
+
import { buildAndCollectFrontend, commandAvailable } from "./frontend-build.js";
|
|
21
27
|
import {
|
|
22
28
|
emitStarter,
|
|
23
29
|
loadCatalog,
|
|
@@ -41,7 +47,7 @@ Commands:
|
|
|
41
47
|
projects List your projects
|
|
42
48
|
create [path] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
|
|
43
49
|
link [projectId] Write .robodev in the current folder
|
|
44
|
-
deploy [--force] Deploy
|
|
50
|
+
deploy [--force] Deploy schema, APIs, jobs, and frontend (local Vite build when .robodev frontend is set)
|
|
45
51
|
`);
|
|
46
52
|
process.exit(1);
|
|
47
53
|
}
|
|
@@ -184,23 +190,53 @@ async function runLink(projectId?: string): Promise<void> {
|
|
|
184
190
|
if (!project) {
|
|
185
191
|
throw new Error(`Project ${chosen} not found or not yours`);
|
|
186
192
|
}
|
|
187
|
-
await
|
|
193
|
+
const existing = await readLinkJson();
|
|
194
|
+
const link: ProjectLink = {
|
|
188
195
|
projectId: project.id,
|
|
189
196
|
apiUrl: starbaseUrl(),
|
|
190
197
|
projectApiUrl: project.apiUrl,
|
|
191
|
-
}
|
|
198
|
+
};
|
|
199
|
+
if ((!existing || !("frontend" in existing)) && (await hasAppsFePackageJson(process.cwd()))) {
|
|
200
|
+
link.frontend = defaultFrontendConfig();
|
|
201
|
+
}
|
|
202
|
+
await writeLink(link);
|
|
192
203
|
await writeViteApiUrl(project.apiUrl);
|
|
193
204
|
console.log(`Linked ${process.cwd()} to ${project.id}`);
|
|
194
205
|
console.log(`Frontend API ${project.apiUrl}`);
|
|
195
206
|
}
|
|
196
207
|
|
|
208
|
+
type DeployOptions = {
|
|
209
|
+
root?: string;
|
|
210
|
+
frontendFiles?: CollectedFile[];
|
|
211
|
+
skipFrontendBuild?: boolean;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
async function collectDeployFiles(
|
|
215
|
+
root: string,
|
|
216
|
+
link: ProjectLink,
|
|
217
|
+
options: DeployOptions,
|
|
218
|
+
): Promise<CollectedFile[]> {
|
|
219
|
+
const collected = await collectFiles(root);
|
|
220
|
+
if (options.frontendFiles) {
|
|
221
|
+
return mergeCollectedFiles(collected, options.frontendFiles);
|
|
222
|
+
}
|
|
223
|
+
if (options.skipFrontendBuild || !isFrontendConfig(link.frontend)) {
|
|
224
|
+
return collected;
|
|
225
|
+
}
|
|
226
|
+
const frontendFiles = await buildAndCollectFrontend(root, link.frontend, {
|
|
227
|
+
install: "if-needed",
|
|
228
|
+
});
|
|
229
|
+
return mergeCollectedFiles(collected, frontendFiles);
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
/** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
|
|
198
|
-
async function runDeploy(forceFlag: boolean,
|
|
233
|
+
async function runDeploy(forceFlag: boolean, options: DeployOptions = {}): Promise<void> {
|
|
234
|
+
const root = options.root ?? process.cwd();
|
|
199
235
|
const link = await readLink(root);
|
|
200
236
|
if (!link) {
|
|
201
237
|
throw new Error("No .robodev file. Run `robodev link` or `robodev create` first.");
|
|
202
238
|
}
|
|
203
|
-
const files = await
|
|
239
|
+
const files = await collectDeployFiles(root, link, options);
|
|
204
240
|
let force = forceFlag;
|
|
205
241
|
|
|
206
242
|
for (;;) {
|
|
@@ -397,15 +433,6 @@ async function npmInstall(root: string): Promise<void> {
|
|
|
397
433
|
});
|
|
398
434
|
}
|
|
399
435
|
|
|
400
|
-
async function bunAvailable(): Promise<boolean> {
|
|
401
|
-
try {
|
|
402
|
-
await execFileAsync("bun", ["--version"]);
|
|
403
|
-
return true;
|
|
404
|
-
} catch {
|
|
405
|
-
return false;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
436
|
async function hasAppsFe(root: string): Promise<boolean> {
|
|
410
437
|
try {
|
|
411
438
|
return (await stat(join(root, "apps/fe"))).isDirectory();
|
|
@@ -414,7 +441,7 @@ async function hasAppsFe(root: string): Promise<boolean> {
|
|
|
414
441
|
}
|
|
415
442
|
}
|
|
416
443
|
|
|
417
|
-
function printFeNextSteps(displayPath: string): void {
|
|
444
|
+
function printFeNextSteps(displayPath: string, hosted: boolean): void {
|
|
418
445
|
if (displayPath !== ".") {
|
|
419
446
|
console.log(` cd ${displayPath}`);
|
|
420
447
|
}
|
|
@@ -422,9 +449,15 @@ function printFeNextSteps(displayPath: string): void {
|
|
|
422
449
|
console.log(" bun install");
|
|
423
450
|
console.log(" bun openapi:gen");
|
|
424
451
|
console.log(" bun dev");
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
452
|
+
if (hosted) {
|
|
453
|
+
console.log(
|
|
454
|
+
" The project App URL serves the hosted SPA. Use bun in apps/fe (Vite default http://localhost:3000) to iterate locally.",
|
|
455
|
+
);
|
|
456
|
+
} else {
|
|
457
|
+
console.log(
|
|
458
|
+
" The project App URL is APIs and Swagger until a frontend build succeeds. Use bun in apps/fe (Vite default http://localhost:3000) to iterate locally.",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
428
461
|
}
|
|
429
462
|
|
|
430
463
|
/** Creates a Starbase project, copies the chosen starter, links, deploys, and installs deps. */
|
|
@@ -451,18 +484,35 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
451
484
|
title: starter.title,
|
|
452
485
|
versions: await loadPackageVersions(),
|
|
453
486
|
});
|
|
487
|
+
const wantFrontend = await hasAppsFePackageJson(root);
|
|
454
488
|
await writeLink(
|
|
455
489
|
{
|
|
456
490
|
projectId: project.id,
|
|
457
491
|
apiUrl: starbaseUrl(),
|
|
458
492
|
projectApiUrl: project.apiUrl,
|
|
493
|
+
...(wantFrontend ? { frontend: defaultFrontendConfig() } : {}),
|
|
459
494
|
},
|
|
460
495
|
root,
|
|
461
496
|
);
|
|
462
497
|
await writeViteApiUrl(project.apiUrl, root);
|
|
463
498
|
|
|
464
499
|
console.log(`Created ${name} (${project.id}) in ${root}`);
|
|
465
|
-
|
|
500
|
+
let frontendFiles: CollectedFile[] | undefined;
|
|
501
|
+
let skipFrontendBuild = false;
|
|
502
|
+
let feHosted = false;
|
|
503
|
+
if (wantFrontend) {
|
|
504
|
+
try {
|
|
505
|
+
frontendFiles = await buildAndCollectFrontend(root, defaultFrontendConfig(), {
|
|
506
|
+
install: "always",
|
|
507
|
+
});
|
|
508
|
+
feHosted = true;
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.log(error instanceof Error ? error.message : error);
|
|
511
|
+
console.log("Frontend build failed. Deploying schema and APIs only.");
|
|
512
|
+
skipFrontendBuild = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
await runDeploy(false, { root, frontendFiles, skipFrontendBuild });
|
|
466
516
|
|
|
467
517
|
try {
|
|
468
518
|
await npmInstall(root);
|
|
@@ -486,10 +536,10 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
486
536
|
console.log("");
|
|
487
537
|
console.log("Next:");
|
|
488
538
|
if (await hasAppsFe(root)) {
|
|
489
|
-
if (!(await
|
|
539
|
+
if (!(await commandAvailable("bun"))) {
|
|
490
540
|
console.log("bun is required for apps/fe. APIs are already deployed.");
|
|
491
541
|
}
|
|
492
|
-
printFeNextSteps(displayPath);
|
|
542
|
+
printFeNextSteps(displayPath, feHosted);
|
|
493
543
|
} else if (starter.kind === "backend") {
|
|
494
544
|
if (displayPath !== ".") {
|
|
495
545
|
console.log(` cd ${displayPath}`);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# {{NAME}}
|
|
2
2
|
|
|
3
|
-
Starter from `robodev create --starter auth-chat`: Tiny chat APIs (`chat` database, `messages` table), authenticated invite email, and a
|
|
3
|
+
Starter from `robodev create --starter auth-chat`: Tiny chat APIs (`chat` database, `messages` table), authenticated invite email, and a Povio UI in `apps/fe`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
After a CLI create or deploy with `.robodev` `frontend` set (the default for this starter), the project host serves the built Tiny SPA at `GET /`. APIs and Swagger still win over static files. Use bun in `apps/fe` to iterate locally:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
cd apps/fe
|
|
@@ -21,9 +21,9 @@ npx robodev link
|
|
|
21
21
|
npx robodev deploy
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
`robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`,
|
|
24
|
+
`robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, rewrite `.config/local.spa.yml` so the FE talks to the project host, and (when `apps/fe/package.json` exists) add a `frontend` block to `.robodev`. `robodev deploy` then runs the local Vite build and uploads the static output.
|
|
25
25
|
|
|
26
|
-
Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create
|
|
26
|
+
Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`; a later CLI deploy hosts the SPA.
|
|
27
27
|
|
|
28
28
|
`GET /api/me`, `GET/POST /api/messages`, and `POST /api/invite` require a Robodev Auth Bearer token. `GET /api/health` stays public. Invites send one email; posting a chat message does not. Auth stays reserved at `/api/user/*`. Do not add `api/user/**`.
|
|
29
29
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "bunx --bun vite",
|
|
9
|
-
"build": "bunx --bun vite build",
|
|
9
|
+
"build": "bun run openapi:gen && bunx --bun vite build",
|
|
10
10
|
"clean": "rimraf dist src/openapi",
|
|
11
11
|
"lint": "oxlint --type-aware . ../../api ../../database.ts --fix",
|
|
12
12
|
"lint:check": "oxlint --type-aware . ../../api ../../database.ts --quiet",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# {{NAME}}
|
|
2
2
|
|
|
3
|
-
Starter from `robodev create`: Tiny Template schema (`planets`, `aliens`, `media`, `planet_likes`), file-based APIs in `api/`, and a
|
|
3
|
+
Starter from `robodev create`: Tiny Template schema (`planets`, `aliens`, `media`, `planet_likes`), file-based APIs in `api/`, and a Povio UI in `apps/fe`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
After a CLI create or deploy with `.robodev` `frontend` set (the default for this starter), the project host serves the built Tiny SPA at `GET /`. APIs and Swagger still win over static files. Use bun in `apps/fe` to iterate locally:
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
cd apps/fe
|
|
@@ -21,9 +21,9 @@ npx robodev link
|
|
|
21
21
|
npx robodev deploy
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
`robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`,
|
|
24
|
+
`robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, rewrite `.config/local.spa.yml` so the FE talks to the project host, and (when `apps/fe/package.json` exists) add a `frontend` block to `.robodev`. `robodev deploy` then runs the local Vite build and uploads the static output.
|
|
25
25
|
|
|
26
|
-
Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create
|
|
26
|
+
Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`; a later CLI deploy hosts the SPA.
|
|
27
27
|
|
|
28
28
|
Auth stays reserved at `/api/user/*`. Do not add `api/user/**`.
|
|
29
29
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "bunx --bun vite",
|
|
9
|
-
"build": "bunx --bun vite build",
|
|
9
|
+
"build": "bun run openapi:gen && bunx --bun vite build",
|
|
10
10
|
"clean": "rimraf dist src/openapi",
|
|
11
11
|
"lint": "oxlint --type-aware . ../../api ../../database.ts --fix",
|
|
12
12
|
"lint:check": "oxlint --type-aware . ../../api ../../database.ts --quiet",
|