robodev 0.13.0 → 0.15.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/frontend-build.test.ts +59 -0
- package/src/frontend-build.ts +212 -0
- package/src/index.ts +114 -63
- package/src/parse-create-args.test.ts +26 -0
- package/src/parse-create-args.ts +63 -0
- package/templates/auth-chat/README.md +4 -4
- 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/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
|
}
|
|
@@ -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,
|
|
@@ -25,6 +31,7 @@ import {
|
|
|
25
31
|
resolveStarterTree,
|
|
26
32
|
type Starter,
|
|
27
33
|
} from "./emit-starter.js";
|
|
34
|
+
import { parseCreateArgs } from "./parse-create-args.js";
|
|
28
35
|
|
|
29
36
|
const execFileAsync = promisify(execFile);
|
|
30
37
|
|
|
@@ -38,9 +45,9 @@ Commands:
|
|
|
38
45
|
logout Remove stored credentials
|
|
39
46
|
whoami Show the signed-in user
|
|
40
47
|
projects List your projects
|
|
41
|
-
create [path] [--starter <id>] [--empty] Create a project, scaffold a starter, and deploy
|
|
48
|
+
create [path] [--starter <id>] [--empty] [--org <id>] Create a project, scaffold a starter, and deploy
|
|
42
49
|
link [projectId] Write .robodev in the current folder
|
|
43
|
-
deploy [--force] Deploy
|
|
50
|
+
deploy [--force] Deploy schema, APIs, jobs, and frontend (local Vite build when .robodev frontend is set)
|
|
44
51
|
`);
|
|
45
52
|
process.exit(1);
|
|
46
53
|
}
|
|
@@ -183,23 +190,53 @@ async function runLink(projectId?: string): Promise<void> {
|
|
|
183
190
|
if (!project) {
|
|
184
191
|
throw new Error(`Project ${chosen} not found or not yours`);
|
|
185
192
|
}
|
|
186
|
-
await
|
|
193
|
+
const existing = await readLinkJson();
|
|
194
|
+
const link: ProjectLink = {
|
|
187
195
|
projectId: project.id,
|
|
188
196
|
apiUrl: starbaseUrl(),
|
|
189
197
|
projectApiUrl: project.apiUrl,
|
|
190
|
-
}
|
|
198
|
+
};
|
|
199
|
+
if ((!existing || !("frontend" in existing)) && (await hasAppsFePackageJson(process.cwd()))) {
|
|
200
|
+
link.frontend = defaultFrontendConfig();
|
|
201
|
+
}
|
|
202
|
+
await writeLink(link);
|
|
191
203
|
await writeViteApiUrl(project.apiUrl);
|
|
192
204
|
console.log(`Linked ${process.cwd()} to ${project.id}`);
|
|
193
205
|
console.log(`Frontend API ${project.apiUrl}`);
|
|
194
206
|
}
|
|
195
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
|
+
|
|
196
232
|
/** Uploads schema, APIs, and frontend. Destructive schema changes need confirmation or `--force`. */
|
|
197
|
-
async function runDeploy(forceFlag: boolean,
|
|
233
|
+
async function runDeploy(forceFlag: boolean, options: DeployOptions = {}): Promise<void> {
|
|
234
|
+
const root = options.root ?? process.cwd();
|
|
198
235
|
const link = await readLink(root);
|
|
199
236
|
if (!link) {
|
|
200
237
|
throw new Error("No .robodev file. Run `robodev link` or `robodev create` first.");
|
|
201
238
|
}
|
|
202
|
-
const files = await
|
|
239
|
+
const files = await collectDeployFiles(root, link, options);
|
|
203
240
|
let force = forceFlag;
|
|
204
241
|
|
|
205
242
|
for (;;) {
|
|
@@ -279,48 +316,47 @@ function validIdsMessage(starters: Starter[]): string {
|
|
|
279
316
|
return `Valid ids: ${starters.map((starter) => starter.id).join(", ")}`;
|
|
280
317
|
}
|
|
281
318
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
if (arg.startsWith("--starter=")) {
|
|
302
|
-
const value = arg.slice("--starter=".length);
|
|
303
|
-
if (!value) {
|
|
304
|
-
throw new Error("Missing value for --starter");
|
|
305
|
-
}
|
|
306
|
-
starterId = value;
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
if (arg.startsWith("-")) {
|
|
310
|
-
throw new Error(`Unknown flag ${arg}`);
|
|
311
|
-
}
|
|
312
|
-
if (path !== undefined) {
|
|
313
|
-
throw new Error(`Unexpected argument ${arg}`);
|
|
314
|
-
}
|
|
315
|
-
path = arg;
|
|
319
|
+
type Organization = {
|
|
320
|
+
id: string;
|
|
321
|
+
name: string;
|
|
322
|
+
plan: string;
|
|
323
|
+
createdAt: string;
|
|
324
|
+
role: "admin" | "developer";
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
function validOrgIdsMessage(orgs: Organization[]): string {
|
|
328
|
+
return `Valid ids: ${orgs.map((org) => org.id).join(", ")}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function resolveOrganization(flagId: string | undefined): Promise<string | undefined> {
|
|
332
|
+
const { organizations } = await authed<{ organizations: Organization[] }>("/v1/organizations");
|
|
333
|
+
const adminOrgs = organizations.filter((org) => org.role === "admin");
|
|
334
|
+
if (flagId !== undefined) {
|
|
335
|
+
return flagId;
|
|
316
336
|
}
|
|
317
|
-
if (
|
|
318
|
-
throw new Error("
|
|
337
|
+
if (adminOrgs.length === 0) {
|
|
338
|
+
throw new Error("You need an admin role in an organization to create a project.");
|
|
319
339
|
}
|
|
320
|
-
if (
|
|
321
|
-
|
|
340
|
+
if (adminOrgs.length === 1 || !stdin.isTTY) {
|
|
341
|
+
return undefined;
|
|
322
342
|
}
|
|
323
|
-
|
|
343
|
+
console.log("Organizations:");
|
|
344
|
+
adminOrgs.forEach((org, i) => {
|
|
345
|
+
console.log(` ${i + 1}. ${org.name} (${org.id})`);
|
|
346
|
+
});
|
|
347
|
+
const answer = (await prompt("Organization number or id: ")).trim();
|
|
348
|
+
if (!answer) {
|
|
349
|
+
throw new Error(`Unknown organization "". ${validOrgIdsMessage(adminOrgs)}`);
|
|
350
|
+
}
|
|
351
|
+
const asIndex = Number(answer);
|
|
352
|
+
if (Number.isInteger(asIndex) && asIndex >= 1 && asIndex <= adminOrgs.length) {
|
|
353
|
+
return adminOrgs[asIndex - 1].id;
|
|
354
|
+
}
|
|
355
|
+
const found = adminOrgs.find((org) => org.id === answer);
|
|
356
|
+
if (!found) {
|
|
357
|
+
throw new Error(`Unknown organization "${answer}". ${validOrgIdsMessage(adminOrgs)}`);
|
|
358
|
+
}
|
|
359
|
+
return found.id;
|
|
324
360
|
}
|
|
325
361
|
|
|
326
362
|
async function resolveStarter(flagId: string | undefined): Promise<Starter> {
|
|
@@ -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. */
|
|
@@ -438,9 +471,10 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
438
471
|
await assertCreatable(root);
|
|
439
472
|
|
|
440
473
|
const name = projectNameFromPath(root);
|
|
474
|
+
const organizationId = await resolveOrganization(parsed.orgId);
|
|
441
475
|
const project = await authed<Project>("/v1/projects", {
|
|
442
476
|
method: "POST",
|
|
443
|
-
body: JSON.stringify({ name }),
|
|
477
|
+
body: JSON.stringify(organizationId ? { name, organizationId } : { name }),
|
|
444
478
|
});
|
|
445
479
|
|
|
446
480
|
const { treeRoot } = await resolveStarterTree();
|
|
@@ -450,18 +484,35 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
450
484
|
title: starter.title,
|
|
451
485
|
versions: await loadPackageVersions(),
|
|
452
486
|
});
|
|
487
|
+
const wantFrontend = await hasAppsFePackageJson(root);
|
|
453
488
|
await writeLink(
|
|
454
489
|
{
|
|
455
490
|
projectId: project.id,
|
|
456
491
|
apiUrl: starbaseUrl(),
|
|
457
492
|
projectApiUrl: project.apiUrl,
|
|
493
|
+
...(wantFrontend ? { frontend: defaultFrontendConfig() } : {}),
|
|
458
494
|
},
|
|
459
495
|
root,
|
|
460
496
|
);
|
|
461
497
|
await writeViteApiUrl(project.apiUrl, root);
|
|
462
498
|
|
|
463
499
|
console.log(`Created ${name} (${project.id}) in ${root}`);
|
|
464
|
-
|
|
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 });
|
|
465
516
|
|
|
466
517
|
try {
|
|
467
518
|
await npmInstall(root);
|
|
@@ -485,10 +536,10 @@ async function runCreate(args: string[]): Promise<void> {
|
|
|
485
536
|
console.log("");
|
|
486
537
|
console.log("Next:");
|
|
487
538
|
if (await hasAppsFe(root)) {
|
|
488
|
-
if (!(await
|
|
539
|
+
if (!(await commandAvailable("bun"))) {
|
|
489
540
|
console.log("bun is required for apps/fe. APIs are already deployed.");
|
|
490
541
|
}
|
|
491
|
-
printFeNextSteps(displayPath);
|
|
542
|
+
printFeNextSteps(displayPath, feHosted);
|
|
492
543
|
} else if (starter.kind === "backend") {
|
|
493
544
|
if (displayPath !== ".") {
|
|
494
545
|
console.log(` cd ${displayPath}`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { parseCreateArgs } from "./parse-create-args.js";
|
|
4
|
+
|
|
5
|
+
test("parseCreateArgs accepts --org with starter flags", () => {
|
|
6
|
+
assert.deepEqual(parseCreateArgs(["app", "--starter", "space", "--org", "org_1"]), {
|
|
7
|
+
path: "app",
|
|
8
|
+
starterId: "space",
|
|
9
|
+
orgId: "org_1",
|
|
10
|
+
});
|
|
11
|
+
assert.deepEqual(parseCreateArgs(["--empty", "--org=org_2"]), {
|
|
12
|
+
path: undefined,
|
|
13
|
+
starterId: "empty",
|
|
14
|
+
orgId: "org_2",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parseCreateArgs rejects a missing --org value", () => {
|
|
19
|
+
assert.throws(() => parseCreateArgs(["--org"]), /Missing value for --org/);
|
|
20
|
+
assert.throws(() => parseCreateArgs(["--org="]), /Missing value for --org/);
|
|
21
|
+
assert.throws(() => parseCreateArgs(["--org", "--empty"]), /Missing value for --org/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("parseCreateArgs rejects unknown flags including --organization", () => {
|
|
25
|
+
assert.throws(() => parseCreateArgs(["--organization", "org_1"]), /Unknown flag --organization/);
|
|
26
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type CreateArgs = { path?: string; starterId?: string; orgId?: string };
|
|
2
|
+
|
|
3
|
+
export function parseCreateArgs(args: string[]): CreateArgs {
|
|
4
|
+
let path: string | undefined;
|
|
5
|
+
let starterId: string | undefined;
|
|
6
|
+
let orgId: string | undefined;
|
|
7
|
+
let empty = false;
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const arg = args[i];
|
|
10
|
+
if (arg === "--empty") {
|
|
11
|
+
empty = true;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (arg === "--starter") {
|
|
15
|
+
const value = args[i + 1];
|
|
16
|
+
if (!value || value.startsWith("-")) {
|
|
17
|
+
throw new Error("Missing value for --starter");
|
|
18
|
+
}
|
|
19
|
+
starterId = value;
|
|
20
|
+
i += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (arg.startsWith("--starter=")) {
|
|
24
|
+
const value = arg.slice("--starter=".length);
|
|
25
|
+
if (!value) {
|
|
26
|
+
throw new Error("Missing value for --starter");
|
|
27
|
+
}
|
|
28
|
+
starterId = value;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (arg === "--org") {
|
|
32
|
+
const value = args[i + 1];
|
|
33
|
+
if (!value || value.startsWith("-")) {
|
|
34
|
+
throw new Error("Missing value for --org");
|
|
35
|
+
}
|
|
36
|
+
orgId = value;
|
|
37
|
+
i += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg.startsWith("--org=")) {
|
|
41
|
+
const value = arg.slice("--org=".length);
|
|
42
|
+
if (!value) {
|
|
43
|
+
throw new Error("Missing value for --org");
|
|
44
|
+
}
|
|
45
|
+
orgId = value;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg.startsWith("-")) {
|
|
49
|
+
throw new Error(`Unknown flag ${arg}`);
|
|
50
|
+
}
|
|
51
|
+
if (path !== undefined) {
|
|
52
|
+
throw new Error(`Unexpected argument ${arg}`);
|
|
53
|
+
}
|
|
54
|
+
path = arg;
|
|
55
|
+
}
|
|
56
|
+
if (empty && starterId !== undefined) {
|
|
57
|
+
throw new Error("Use either --empty or --starter, not both.");
|
|
58
|
+
}
|
|
59
|
+
if (empty) {
|
|
60
|
+
starterId = "empty";
|
|
61
|
+
}
|
|
62
|
+
return { path, starterId, orgId };
|
|
63
|
+
}
|
|
@@ -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
|
|
|
@@ -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
|
|