rowork 0.1.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 +21 -0
- package/README.md +95 -0
- package/bin/rowork.js +18 -0
- package/dashboard/app.css +234 -0
- package/dashboard/app.js +117 -0
- package/dashboard/index.html +67 -0
- package/dist/cli/errors.d.ts +15 -0
- package/dist/cli/errors.js +18 -0
- package/dist/cli/program.d.ts +19 -0
- package/dist/cli/program.js +59 -0
- package/dist/cli/registry.d.ts +37 -0
- package/dist/cli/registry.js +61 -0
- package/dist/commands/add.d.ts +8 -0
- package/dist/commands/add.js +77 -0
- package/dist/commands/assets-setup.d.ts +18 -0
- package/dist/commands/assets-setup.js +136 -0
- package/dist/commands/assets.d.ts +2 -0
- package/dist/commands/assets.js +153 -0
- package/dist/commands/console.d.ts +4 -0
- package/dist/commands/console.js +141 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +50 -0
- package/dist/commands/dev-background.d.ts +3 -0
- package/dist/commands/dev-background.js +87 -0
- package/dist/commands/dev.d.ts +2 -0
- package/dist/commands/dev.js +270 -0
- package/dist/commands/eject.d.ts +2 -0
- package/dist/commands/eject.js +136 -0
- package/dist/commands/index.d.ts +8 -0
- package/dist/commands/index.js +54 -0
- package/dist/commands/info.d.ts +3 -0
- package/dist/commands/info.js +50 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +55 -0
- package/dist/commands/links.d.ts +32 -0
- package/dist/commands/links.js +152 -0
- package/dist/commands/make-event.d.ts +2 -0
- package/dist/commands/make-event.js +160 -0
- package/dist/commands/make-menu.d.ts +3 -0
- package/dist/commands/make-menu.js +36 -0
- package/dist/commands/make-stat.d.ts +2 -0
- package/dist/commands/make-stat.js +199 -0
- package/dist/commands/make-tool.d.ts +2 -0
- package/dist/commands/make-tool.js +124 -0
- package/dist/commands/make-ui.d.ts +3 -0
- package/dist/commands/make-ui.js +199 -0
- package/dist/commands/make.d.ts +21 -0
- package/dist/commands/make.js +181 -0
- package/dist/commands/next-steps.d.ts +5 -0
- package/dist/commands/next-steps.js +18 -0
- package/dist/commands/start.d.ts +2 -0
- package/dist/commands/start.js +144 -0
- package/dist/commands/studio.d.ts +3 -0
- package/dist/commands/studio.js +45 -0
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +173 -0
- package/dist/commands/wire.d.ts +2 -0
- package/dist/commands/wire.js +34 -0
- package/dist/core/agent-docs.d.ts +23 -0
- package/dist/core/agent-docs.js +151 -0
- package/dist/core/assets.d.ts +96 -0
- package/dist/core/assets.js +268 -0
- package/dist/core/background.d.ts +47 -0
- package/dist/core/background.js +149 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +62 -0
- package/dist/core/exec.d.ts +22 -0
- package/dist/core/exec.js +38 -0
- package/dist/core/format-generated.d.ts +12 -0
- package/dist/core/format-generated.js +28 -0
- package/dist/core/generate.d.ts +37 -0
- package/dist/core/generate.js +94 -0
- package/dist/core/github-release.d.ts +28 -0
- package/dist/core/github-release.js +38 -0
- package/dist/core/info.d.ts +77 -0
- package/dist/core/info.js +65 -0
- package/dist/core/modules.d.ts +32 -0
- package/dist/core/modules.js +265 -0
- package/dist/core/naming.d.ts +6 -0
- package/dist/core/naming.js +26 -0
- package/dist/core/open-cloud.d.ts +58 -0
- package/dist/core/open-cloud.js +170 -0
- package/dist/core/project-index.d.ts +26 -0
- package/dist/core/project-index.js +59 -0
- package/dist/core/rojo-edit.d.ts +18 -0
- package/dist/core/rojo-edit.js +53 -0
- package/dist/core/rokit-installer.d.ts +15 -0
- package/dist/core/rokit-installer.js +77 -0
- package/dist/core/scaffold.d.ts +44 -0
- package/dist/core/scaffold.js +163 -0
- package/dist/core/schema-edit.d.ts +46 -0
- package/dist/core/schema-edit.js +125 -0
- package/dist/core/source-scan.d.ts +24 -0
- package/dist/core/source-scan.js +84 -0
- package/dist/core/studio.d.ts +83 -0
- package/dist/core/studio.js +225 -0
- package/dist/core/toolchain.d.ts +31 -0
- package/dist/core/toolchain.js +98 -0
- package/dist/core/tsconfig-edit.d.ts +11 -0
- package/dist/core/tsconfig-edit.js +47 -0
- package/dist/core/ui-edit.d.ts +31 -0
- package/dist/core/ui-edit.js +89 -0
- package/dist/core/versions.d.ts +24 -0
- package/dist/core/versions.js +67 -0
- package/dist/dashboard/open.d.ts +3 -0
- package/dist/dashboard/open.js +18 -0
- package/dist/dashboard/server.d.ts +28 -0
- package/dist/dashboard/server.js +191 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/main.d.ts +9 -0
- package/dist/main.js +135 -0
- package/dist/modules/format.d.ts +3 -0
- package/dist/modules/format.js +28 -0
- package/dist/modules/index.d.ts +7 -0
- package/dist/modules/index.js +12 -0
- package/dist/modules/integrations.d.ts +8 -0
- package/dist/modules/integrations.js +54 -0
- package/dist/modules/leaderstats.d.ts +3 -0
- package/dist/modules/leaderstats.js +104 -0
- package/dist/modules/lint.d.ts +5 -0
- package/dist/modules/lint.js +43 -0
- package/dist/modules/networking.d.ts +23 -0
- package/dist/modules/networking.js +246 -0
- package/dist/modules/player-data.d.ts +19 -0
- package/dist/modules/player-data.js +176 -0
- package/dist/modules/types.d.ts +96 -0
- package/dist/modules/types.js +2 -0
- package/dist/modules/ui.d.ts +3 -0
- package/dist/modules/ui.js +67 -0
- package/dist/plugins/api.d.ts +127 -0
- package/dist/plugins/api.js +21 -0
- package/dist/plugins/loader.d.ts +20 -0
- package/dist/plugins/loader.js +128 -0
- package/dist/process/log-mux.d.ts +13 -0
- package/dist/process/log-mux.js +31 -0
- package/dist/process/supervisor.d.ts +48 -0
- package/dist/process/supervisor.js +141 -0
- package/dist/process/tree-kill.d.ts +25 -0
- package/dist/process/tree-kill.js +70 -0
- package/dist/templates/engine.d.ts +18 -0
- package/dist/templates/engine.js +54 -0
- package/dist/ui/logger.d.ts +5 -0
- package/dist/ui/logger.js +53 -0
- package/dist/ui/prompt.d.ts +9 -0
- package/dist/ui/prompt.js +24 -0
- package/package.json +69 -0
- package/templates/init/README.md.tmpl +37 -0
- package/templates/init/_gitignore.tmpl +23 -0
- package/templates/init/default.project.json.tmpl +57 -0
- package/templates/init/package.json.tmpl +12 -0
- package/templates/init/rokit.toml.tmpl +5 -0
- package/templates/init/src/client/controllers/ExampleController.ts.tmpl +12 -0
- package/templates/init/src/client/runtime.client.ts.tmpl +6 -0
- package/templates/init/src/server/runtime.server.ts.tmpl +12 -0
- package/templates/init/src/server/services/ExampleService.ts.tmpl +12 -0
- package/templates/init/src/shared/_gitkeep.tmpl +0 -0
- package/templates/init/tsconfig.json.tmpl +28 -0
- package/templates/integrations/data-replication/DataReplicationService.ts.tmpl +33 -0
- package/templates/integrations/data-replication/PlayerDataController.ts.tmpl +40 -0
- package/templates/integrations/data-replication/dataEvents.ts.tmpl +21 -0
- package/templates/make/component.ts.tmpl +8 -0
- package/templates/make/controller.ts.tmpl +6 -0
- package/templates/make/event-handler.ts.tmpl +22 -0
- package/templates/make/screen.tsx.tmpl +35 -0
- package/templates/make/service.ts.tmpl +6 -0
- package/templates/make/stat-service.ts.tmpl +26 -0
- package/templates/make/ui-button.tsx.tmpl +22 -0
- package/templates/make/ui-image.tsx.tmpl +11 -0
- package/templates/make/ui-label.tsx.tmpl +18 -0
- package/templates/make/ui-panel.tsx.tmpl +33 -0
- package/templates/make/ui-story.tsx.tmpl +13 -0
- package/templates/modules/format/prettierignore.tmpl +9 -0
- package/templates/modules/format/prettierrc.json.tmpl +4 -0
- package/templates/modules/leaderstats/LeaderstatsService.ts.tmpl +69 -0
- package/templates/modules/lint/eslint.config.mjs.tmpl +17 -0
- package/templates/modules/networking/client-network.ts.tmpl +9 -0
- package/templates/modules/networking/networking.ts.tmpl +22 -0
- package/templates/modules/networking/rate-limit.ts.tmpl +52 -0
- package/templates/modules/networking/server-network.ts.tmpl +20 -0
- package/templates/modules/player-data/PlayerData.ts.tmpl +15 -0
- package/templates/modules/player-data/PlayerDataService.ts.tmpl +127 -0
- package/templates/modules/ui/App.tsx.tmpl +19 -0
- package/templates/modules/ui/Button.story.tsx.tmpl +13 -0
- package/templates/modules/ui/Button.tsx.tmpl +21 -0
- package/templates/modules/ui/UiController.tsx.tmpl +21 -0
- package/templates/modules/ui/screens/HomeScreen.tsx.tmpl +20 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { activeRegistry } from "../cli/registry.js";
|
|
4
|
+
import { coreModules } from "../modules/index.js";
|
|
5
|
+
import { coreIntegrations } from "../modules/integrations.js";
|
|
6
|
+
export const AGENTS_FILE = "AGENTS.md";
|
|
7
|
+
export const CLAUDE_FILE = "CLAUDE.md";
|
|
8
|
+
const BEGIN = "<!-- rowork:begin: generated by Rowork and rewritten by `rowork add`. Put your own notes OUTSIDE this block. -->";
|
|
9
|
+
const END = "<!-- rowork:end -->";
|
|
10
|
+
/**
|
|
11
|
+
* The block Rowork owns in AGENTS.md.
|
|
12
|
+
*
|
|
13
|
+
* Built from live data (the registered commands, the installed modules) so it
|
|
14
|
+
* cannot drift from what the project really offers. It is written for two
|
|
15
|
+
* readers at once: a person learning the project, and an AI about to change it.
|
|
16
|
+
*/
|
|
17
|
+
export function renderManagedSection(config, roworkVersion) {
|
|
18
|
+
const installed = config.modules ?? [];
|
|
19
|
+
const commands = (activeRegistry()?.all() ?? [])
|
|
20
|
+
.map((entry) => entry.definition)
|
|
21
|
+
.filter((definition) => !["console", "help"].includes(definition.name));
|
|
22
|
+
const lines = [
|
|
23
|
+
BEGIN,
|
|
24
|
+
`## ${config.name}: a Rowork project`,
|
|
25
|
+
"",
|
|
26
|
+
`Generated by Rowork ${roworkVersion}. roblox-ts (TypeScript compiled to Luau) + Flamework (services, controllers, components, dependency injection) + Rojo (sync into Roblox Studio).`,
|
|
27
|
+
"",
|
|
28
|
+
"### How to work here",
|
|
29
|
+
"",
|
|
30
|
+
"- **`rowork make:*` creates one file, `rowork add:*` installs a feature.** `make` puts an (almost empty) file in the right place and connects it wherever it must be included; you write its logic. `add` installs a ready-made, working pack of files (saved player data, typed networking...). Do not reimplement in `make` files what an `add` module already provides.",
|
|
31
|
+
"- **Create things with Rowork instead of writing the files by hand.** It names them, puts them in the right directory and registers them with Flamework (a class in a directory not passed to `Flamework.addPaths` compiles and then silently never runs).",
|
|
32
|
+
"- **Always pass every argument.** Without arguments a command opens interactive questions and waits for a terminal. Commands marked *guided* below do that.",
|
|
33
|
+
"- **Modules connect themselves.** When two installed modules work together, `rowork add` generates the glue (see \"Modules wired together\"); `rowork wire` retries it. Do not write that glue by hand.",
|
|
34
|
+
"- **Know what exists first:** `rowork info --json` prints the project, the installed modules and every command with its options, as JSON.",
|
|
35
|
+
"- **Check your work:** `npm run build` (or `npx rbxtsc`) must finish with no error. In Studio, `rowork dev -d` runs everything in the background, `rowork dev:logs` shows compiler errors, `rowork dev:stop` stops it.",
|
|
36
|
+
"- Files starting with `// Generated by` are rewritten by Rowork: do not edit them.",
|
|
37
|
+
"- Never edit `node_modules/`, `out/`, `include/` or `sourcemap.json`. They are build output.",
|
|
38
|
+
"",
|
|
39
|
+
"### Layout",
|
|
40
|
+
"",
|
|
41
|
+
`- \`${config.paths.services}/\`: Flamework services (server logic). \`${config.paths.controllers}/\`: controllers (client logic). \`${config.paths.shared}/\`: code shared by both.`,
|
|
42
|
+
`- \`${config.paths.source}/server/runtime.server.ts\` and \`${config.paths.source}/client/runtime.client.ts\`: entry points listing the directories Flamework scans.`,
|
|
43
|
+
"- `default.project.json`: what appears in Studio. `rokit.toml`: pinned Rojo version. `rowork.json`: Rowork's own configuration.",
|
|
44
|
+
"",
|
|
45
|
+
"### Installed modules",
|
|
46
|
+
"",
|
|
47
|
+
];
|
|
48
|
+
if (installed.length === 0) {
|
|
49
|
+
lines.push("None yet. `rowork add` lists what can be added (player data, leaderstats, typed networking...).", "");
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
for (const name of installed) {
|
|
53
|
+
const module = coreModules.find((candidate) => candidate.name === name);
|
|
54
|
+
lines.push(`#### ${module?.title ?? name} (\`${name}\`)`, "");
|
|
55
|
+
if (module?.agentGuide === undefined)
|
|
56
|
+
lines.push("Installed with `rowork add`. Read its files.");
|
|
57
|
+
else
|
|
58
|
+
for (const line of module.agentGuide)
|
|
59
|
+
lines.push(`- ${line}`);
|
|
60
|
+
lines.push("");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const wired = coreIntegrations.filter((integration) => (config.integrations ?? []).includes(integration.name));
|
|
64
|
+
if (wired.length > 0) {
|
|
65
|
+
lines.push("### Modules wired together", "", "Rowork generated this glue because the modules below are both installed. Use it instead of writing it again.", "");
|
|
66
|
+
for (const integration of wired) {
|
|
67
|
+
lines.push(`#### ${integration.title} (\`${integration.modules.join(" + ")}\`)`, "");
|
|
68
|
+
for (const line of integration.agentGuide ?? [integration.description])
|
|
69
|
+
lines.push(`- ${line}`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const available = coreModules.filter((module) => !installed.includes(module.name));
|
|
74
|
+
if (available.length > 0) {
|
|
75
|
+
lines.push("### Modules you can still add", "");
|
|
76
|
+
for (const module of available) {
|
|
77
|
+
const needs = module.requires === undefined ? "" : ` (needs: ${module.requires.join(", ")})`;
|
|
78
|
+
lines.push(`- \`rowork add:${module.name}\`: ${module.description}${needs}`);
|
|
79
|
+
}
|
|
80
|
+
lines.push("");
|
|
81
|
+
}
|
|
82
|
+
lines.push("### Commands", "");
|
|
83
|
+
for (const command of commands) {
|
|
84
|
+
const guided = command.guided === true ? " *(guided: pass all arguments)*" : "";
|
|
85
|
+
lines.push(`- \`rowork ${command.name}\`${guided}: ${command.description}`);
|
|
86
|
+
}
|
|
87
|
+
lines.push("", "Every command has `--help`. Use `rowork info --json` for the full option list.", END);
|
|
88
|
+
return `${lines.join("\n")}\n`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Creates or refreshes AGENTS.md (and a CLAUDE.md that points to it).
|
|
92
|
+
*
|
|
93
|
+
* Only the block between the markers is rewritten: anything the user wrote
|
|
94
|
+
* around it stays. An existing CLAUDE.md is never touched. Claude Code reads
|
|
95
|
+
* CLAUDE.md and most other agents read AGENTS.md, so one file holds the content
|
|
96
|
+
* and the other imports it.
|
|
97
|
+
*/
|
|
98
|
+
export function syncAgentDocs(projectRoot, config, roworkVersion) {
|
|
99
|
+
const touched = [];
|
|
100
|
+
const section = renderManagedSection(config, roworkVersion);
|
|
101
|
+
const agentsPath = join(projectRoot, AGENTS_FILE);
|
|
102
|
+
if (!existsSync(agentsPath)) {
|
|
103
|
+
writeFileSync(agentsPath, `# ${config.name}\n\n${section}`, "utf8");
|
|
104
|
+
touched.push(AGENTS_FILE);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const current = readFileSync(agentsPath, "utf8");
|
|
108
|
+
const start = current.indexOf("<!-- rowork:begin");
|
|
109
|
+
const end = current.indexOf(END);
|
|
110
|
+
const next = start !== -1 && end > start
|
|
111
|
+
? `${current.slice(0, start)}${section.trimEnd()}${current.slice(end + END.length)}`
|
|
112
|
+
: `${current.trimEnd()}\n\n${section}`;
|
|
113
|
+
if (next !== current) {
|
|
114
|
+
writeFileSync(agentsPath, next, "utf8");
|
|
115
|
+
touched.push(AGENTS_FILE);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const claudePath = join(projectRoot, CLAUDE_FILE);
|
|
119
|
+
if (!existsSync(claudePath)) {
|
|
120
|
+
writeFileSync(claudePath, "@AGENTS.md\n", "utf8");
|
|
121
|
+
touched.push(CLAUDE_FILE);
|
|
122
|
+
}
|
|
123
|
+
return touched;
|
|
124
|
+
}
|
|
125
|
+
/** Removes the Rowork block when leaving Rowork; deletes files that held nothing else. */
|
|
126
|
+
export function removeAgentDocs(projectRoot) {
|
|
127
|
+
const touched = [];
|
|
128
|
+
const agentsPath = join(projectRoot, AGENTS_FILE);
|
|
129
|
+
if (existsSync(agentsPath)) {
|
|
130
|
+
const current = readFileSync(agentsPath, "utf8");
|
|
131
|
+
const start = current.indexOf("<!-- rowork:begin");
|
|
132
|
+
const end = current.indexOf(END);
|
|
133
|
+
if (start !== -1 && end > start) {
|
|
134
|
+
const rest = `${current.slice(0, start)}${current.slice(end + END.length)}`;
|
|
135
|
+
// A file that only held the generated title and block held nothing of the user's.
|
|
136
|
+
const remaining = rest.replace(/^# .*$/m, "").trim();
|
|
137
|
+
if (remaining === "")
|
|
138
|
+
rmSync(agentsPath);
|
|
139
|
+
else
|
|
140
|
+
writeFileSync(agentsPath, `${rest.trim()}\n`, "utf8");
|
|
141
|
+
touched.push(AGENTS_FILE);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const claudePath = join(projectRoot, CLAUDE_FILE);
|
|
145
|
+
if (existsSync(claudePath) && readFileSync(claudePath, "utf8").trim() === "@AGENTS.md" && !existsSync(agentsPath)) {
|
|
146
|
+
rmSync(claudePath);
|
|
147
|
+
touched.push(CLAUDE_FILE);
|
|
148
|
+
}
|
|
149
|
+
return touched;
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=agent-docs.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AssetType } from "./open-cloud.js";
|
|
2
|
+
export declare const LOCK_FILE = "assets.lock.json";
|
|
3
|
+
export declare const KEY_NAME = "ROWORK_ROBLOX_API_KEY";
|
|
4
|
+
export declare const DEFAULT_FOLDER = "assets";
|
|
5
|
+
/** Roblox's documented ceiling for a model upload. */
|
|
6
|
+
export declare const MAX_MODEL_BYTES: number;
|
|
7
|
+
/** And for a video (also at most 5 minutes and 4096x2160; uploading needs an ID-verified account aged 13 or more). */
|
|
8
|
+
export declare const MAX_VIDEO_BYTES: number;
|
|
9
|
+
export interface AssetFile {
|
|
10
|
+
/** Path inside the assets folder, with `/` on every system. */
|
|
11
|
+
relative: string;
|
|
12
|
+
absolute: string;
|
|
13
|
+
type: AssetType;
|
|
14
|
+
contentType: string;
|
|
15
|
+
sha256: string;
|
|
16
|
+
size: number;
|
|
17
|
+
}
|
|
18
|
+
export interface SkippedFile {
|
|
19
|
+
relative: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
export interface LockEntry {
|
|
23
|
+
sha256: string;
|
|
24
|
+
type: AssetType;
|
|
25
|
+
assetId?: string;
|
|
26
|
+
/** An upload that had not finished: followed again on the next run. */
|
|
27
|
+
operation?: string;
|
|
28
|
+
moderation?: string;
|
|
29
|
+
uploadedAt?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Lock {
|
|
32
|
+
version: 1;
|
|
33
|
+
assets: Record<string, LockEntry>;
|
|
34
|
+
}
|
|
35
|
+
/** Walks the assets folder. Hidden files are ignored, unsupported ones are reported. */
|
|
36
|
+
export declare function scanAssets(projectRoot: string, folder: string): {
|
|
37
|
+
files: AssetFile[];
|
|
38
|
+
skipped: SkippedFile[];
|
|
39
|
+
};
|
|
40
|
+
export declare function readLock(projectRoot: string): Lock;
|
|
41
|
+
/** Sorted keys, so the file only changes when an asset does and diffs stay readable. */
|
|
42
|
+
export declare function writeLock(projectRoot: string, lock: Lock): void;
|
|
43
|
+
export type Action = "upload" | "resume" | "unchanged";
|
|
44
|
+
export interface PlannedAsset {
|
|
45
|
+
file: AssetFile;
|
|
46
|
+
action: Action;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What a run has to do. A file is uploaded again only when its content changed: the
|
|
50
|
+
* hash in the lock file is what makes `rowork assets` safe to run as often as you like.
|
|
51
|
+
* (An image cannot be updated in place, so a changed image becomes a new asset.)
|
|
52
|
+
*/
|
|
53
|
+
export declare function planAssets(files: AssetFile[], lock: Lock): PlannedAsset[];
|
|
54
|
+
/**
|
|
55
|
+
* A moderation state that lets the asset be used. Unknown states are not assumed safe.
|
|
56
|
+
* Roblox writes it as `Approved` (seen with a real upload), its documentation as
|
|
57
|
+
* `MODERATION_STATE_APPROVED`: both are accepted, in any letter case.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isApproved(moderation: string | undefined): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The TypeScript file that lets the code name an asset instead of a number.
|
|
62
|
+
*
|
|
63
|
+
* Only files that still exist and are approved are listed, so the code never points
|
|
64
|
+
* at something that was deleted or refused. It is laid out the way Prettier writes it,
|
|
65
|
+
* because generated code has to pass the project's formatter.
|
|
66
|
+
*/
|
|
67
|
+
export declare function renderAssetsModule(lock: Lock, present: Set<string>): {
|
|
68
|
+
source: string;
|
|
69
|
+
waiting: string[];
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Finds the Open Cloud API key: the environment first, then `.env`.
|
|
73
|
+
*
|
|
74
|
+
* A key that sits in `.env` is refused if git would commit that file. The history of
|
|
75
|
+
* a repository outlives any key rotation, and this one is going to be public.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readApiKey(projectRoot: string): {
|
|
78
|
+
key: string;
|
|
79
|
+
source: "environment" | ".env";
|
|
80
|
+
} | undefined;
|
|
81
|
+
/** A pasted key is one token: no spaces, no quotes, nothing that could break a `.env` line. */
|
|
82
|
+
export declare function looksLikeApiKey(text: string): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Stores the key in `.env`, in that order: first make sure git ignores `.env`, and only
|
|
85
|
+
* then write the key. Other lines of the file are kept. The file is readable by its owner only.
|
|
86
|
+
*/
|
|
87
|
+
export declare function saveApiKey(projectRoot: string, key: string): void;
|
|
88
|
+
export declare function assetsFolder(config: {
|
|
89
|
+
assets?: {
|
|
90
|
+
folder?: string;
|
|
91
|
+
};
|
|
92
|
+
}): string;
|
|
93
|
+
/** Bytes as a short human string. */
|
|
94
|
+
export declare function humanSize(bytes: number): string;
|
|
95
|
+
export declare function statSize(path: string): number;
|
|
96
|
+
//# sourceMappingURL=assets.d.ts.map
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { extname, join } from "node:path";
|
|
5
|
+
import { RoworkError } from "../cli/errors.js";
|
|
6
|
+
import { toFieldName } from "../modules/player-data.js";
|
|
7
|
+
export const LOCK_FILE = "assets.lock.json";
|
|
8
|
+
export const KEY_NAME = "ROWORK_ROBLOX_API_KEY";
|
|
9
|
+
export const DEFAULT_FOLDER = "assets";
|
|
10
|
+
/** What each file extension is uploaded as, from Roblox's list of supported formats. */
|
|
11
|
+
const KINDS = {
|
|
12
|
+
".png": { type: "Image", contentType: "image/png" },
|
|
13
|
+
".jpg": { type: "Image", contentType: "image/jpeg" },
|
|
14
|
+
".jpeg": { type: "Image", contentType: "image/jpeg" },
|
|
15
|
+
".bmp": { type: "Image", contentType: "image/bmp" },
|
|
16
|
+
".tga": { type: "Image", contentType: "image/x-tga" },
|
|
17
|
+
".mp3": { type: "Audio", contentType: "audio/mpeg" },
|
|
18
|
+
".ogg": { type: "Audio", contentType: "audio/ogg" },
|
|
19
|
+
".wav": { type: "Audio", contentType: "audio/wav" },
|
|
20
|
+
".flac": { type: "Audio", contentType: "audio/flac" },
|
|
21
|
+
".fbx": { type: "Model", contentType: "model/fbx" },
|
|
22
|
+
".gltf": { type: "Model", contentType: "model/gltf+json" },
|
|
23
|
+
".glb": { type: "Model", contentType: "model/gltf-binary" },
|
|
24
|
+
".rbxm": { type: "Model", contentType: "model/x-rbxm" },
|
|
25
|
+
".rbxmx": { type: "Model", contentType: "model/x-rbxmx" },
|
|
26
|
+
".mp4": { type: "Video", contentType: "video/mp4" },
|
|
27
|
+
".mov": { type: "Video", contentType: "video/quicktime" },
|
|
28
|
+
};
|
|
29
|
+
/** Roblox's documented ceiling for a model upload. */
|
|
30
|
+
export const MAX_MODEL_BYTES = 20 * 1024 * 1024;
|
|
31
|
+
/** And for a video (also at most 5 minutes and 4096x2160; uploading needs an ID-verified account aged 13 or more). */
|
|
32
|
+
export const MAX_VIDEO_BYTES = 3.75 * 1024 * 1024 * 1024;
|
|
33
|
+
/** Walks the assets folder. Hidden files are ignored, unsupported ones are reported. */
|
|
34
|
+
export function scanAssets(projectRoot, folder) {
|
|
35
|
+
const base = join(projectRoot, folder);
|
|
36
|
+
const files = [];
|
|
37
|
+
const skipped = [];
|
|
38
|
+
const walk = (directory, prefix) => {
|
|
39
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
40
|
+
if (entry.name.startsWith("."))
|
|
41
|
+
continue;
|
|
42
|
+
const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
43
|
+
const absolute = join(directory, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
walk(absolute, relative);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const kind = KINDS[extname(entry.name).toLowerCase()];
|
|
49
|
+
if (kind === undefined) {
|
|
50
|
+
skipped.push({ relative, reason: `${extname(entry.name) || "no extension"} is not a format Rowork uploads yet` });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const content = readFileSync(absolute);
|
|
54
|
+
files.push({
|
|
55
|
+
relative,
|
|
56
|
+
absolute,
|
|
57
|
+
type: kind.type,
|
|
58
|
+
contentType: kind.contentType,
|
|
59
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
60
|
+
size: content.length,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (existsSync(base))
|
|
65
|
+
walk(base, "");
|
|
66
|
+
return { files, skipped };
|
|
67
|
+
}
|
|
68
|
+
export function readLock(projectRoot) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(join(projectRoot, LOCK_FILE), "utf8"));
|
|
71
|
+
return { version: 1, assets: parsed.assets ?? {} };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { version: 1, assets: {} };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Sorted keys, so the file only changes when an asset does and diffs stay readable. */
|
|
78
|
+
export function writeLock(projectRoot, lock) {
|
|
79
|
+
const assets = Object.fromEntries(Object.entries(lock.assets).sort(([a], [b]) => a.localeCompare(b)));
|
|
80
|
+
writeFileSync(join(projectRoot, LOCK_FILE), `${JSON.stringify({ version: 1, assets }, undefined, 2)}\n`, "utf8");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* What a run has to do. A file is uploaded again only when its content changed: the
|
|
84
|
+
* hash in the lock file is what makes `rowork assets` safe to run as often as you like.
|
|
85
|
+
* (An image cannot be updated in place, so a changed image becomes a new asset.)
|
|
86
|
+
*/
|
|
87
|
+
export function planAssets(files, lock) {
|
|
88
|
+
return files.map((file) => {
|
|
89
|
+
const known = lock.assets[file.relative];
|
|
90
|
+
// A different type means it was uploaded the old way (images used to go up as Decals,
|
|
91
|
+
// whose id does not reliably load in an ImageLabel): send it again as what it is now.
|
|
92
|
+
if (known === undefined || known.sha256 !== file.sha256 || known.type !== file.type)
|
|
93
|
+
return { file, action: "upload" };
|
|
94
|
+
if (known.assetId === undefined && known.operation !== undefined)
|
|
95
|
+
return { file, action: "resume" };
|
|
96
|
+
return { file, action: known.assetId === undefined ? "upload" : "unchanged" };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A moderation state that lets the asset be used. Unknown states are not assumed safe.
|
|
101
|
+
* Roblox writes it as `Approved` (seen with a real upload), its documentation as
|
|
102
|
+
* `MODERATION_STATE_APPROVED`: both are accepted, in any letter case.
|
|
103
|
+
*/
|
|
104
|
+
export function isApproved(moderation) {
|
|
105
|
+
return moderation === undefined || /^(?:MODERATION_STATE_)?APPROVED$/i.test(moderation);
|
|
106
|
+
}
|
|
107
|
+
function usable(entry) {
|
|
108
|
+
return entry.assetId !== undefined && isApproved(entry.moderation);
|
|
109
|
+
}
|
|
110
|
+
function keyFor(segment) {
|
|
111
|
+
return toFieldName(segment.replace(/\.[^.]+$/, "")) || segment;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The TypeScript file that lets the code name an asset instead of a number.
|
|
115
|
+
*
|
|
116
|
+
* Only files that still exist and are approved are listed, so the code never points
|
|
117
|
+
* at something that was deleted or refused. It is laid out the way Prettier writes it,
|
|
118
|
+
* because generated code has to pass the project's formatter.
|
|
119
|
+
*/
|
|
120
|
+
export function renderAssetsModule(lock, present) {
|
|
121
|
+
const tree = {};
|
|
122
|
+
const waiting = [];
|
|
123
|
+
const owner = new Map();
|
|
124
|
+
for (const [relative, entry] of Object.entries(lock.assets)) {
|
|
125
|
+
if (!present.has(relative))
|
|
126
|
+
continue;
|
|
127
|
+
if (!usable(entry)) {
|
|
128
|
+
waiting.push(`${relative} (${entry.moderation ?? "no asset id yet"})`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const segments = relative.split("/");
|
|
132
|
+
let node = tree;
|
|
133
|
+
segments.forEach((segment, index) => {
|
|
134
|
+
const isFile = index === segments.length - 1;
|
|
135
|
+
const key = keyFor(segment);
|
|
136
|
+
const path = segments.slice(0, index + 1).join("/");
|
|
137
|
+
const previous = owner.get(`${segments.slice(0, index).map(keyFor).join(".")}.${key}`);
|
|
138
|
+
if (previous !== undefined && previous !== path) {
|
|
139
|
+
throw new RoworkError(`\`${previous}\` and \`${path}\` would both be called \`${key}\`.`, {
|
|
140
|
+
hint: "Rename one of them so each asset has its own name.",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
owner.set(`${segments.slice(0, index).map(keyFor).join(".")}.${key}`, path);
|
|
144
|
+
if (isFile)
|
|
145
|
+
node[key] = `rbxassetid://${entry.assetId}`;
|
|
146
|
+
else
|
|
147
|
+
node = (node[key] = node[key] ?? {});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const quote = (name) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name));
|
|
151
|
+
const lines = [];
|
|
152
|
+
const write = (node, depth) => {
|
|
153
|
+
for (const key of Object.keys(node).sort()) {
|
|
154
|
+
const value = node[key];
|
|
155
|
+
const pad = "\t".repeat(depth);
|
|
156
|
+
if (typeof value === "string") {
|
|
157
|
+
lines.push(`${pad}${quote(key)}: ${JSON.stringify(value)},`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
lines.push(`${pad}${quote(key)}: {`);
|
|
161
|
+
write(value, depth + 1);
|
|
162
|
+
lines.push(`${pad}},`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
write(tree, 1);
|
|
167
|
+
const body = lines.length === 0 ? "{}" : `{\n${lines.join("\n")}\n}`;
|
|
168
|
+
return {
|
|
169
|
+
source: [
|
|
170
|
+
"// Generated by `rowork assets`. Do not edit: this file is rewritten every time.",
|
|
171
|
+
"// Each name is a file in the assets folder; the value is what Roblox knows it as.",
|
|
172
|
+
`export const Assets = ${body} as const;`,
|
|
173
|
+
"",
|
|
174
|
+
].join("\n"),
|
|
175
|
+
waiting,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Reads a `.env` file: `NAME=value`, optional quotes, `#` comments. */
|
|
179
|
+
function readDotenv(projectRoot) {
|
|
180
|
+
let source;
|
|
181
|
+
try {
|
|
182
|
+
source = readFileSync(join(projectRoot, ".env"), "utf8");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
const values = {};
|
|
188
|
+
for (const line of source.split(/\r?\n/)) {
|
|
189
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
190
|
+
if (match === null || line.trim().startsWith("#"))
|
|
191
|
+
continue;
|
|
192
|
+
values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2");
|
|
193
|
+
}
|
|
194
|
+
return values;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Finds the Open Cloud API key: the environment first, then `.env`.
|
|
198
|
+
*
|
|
199
|
+
* A key that sits in `.env` is refused if git would commit that file. The history of
|
|
200
|
+
* a repository outlives any key rotation, and this one is going to be public.
|
|
201
|
+
*/
|
|
202
|
+
export function readApiKey(projectRoot) {
|
|
203
|
+
const fromEnvironment = process.env[KEY_NAME];
|
|
204
|
+
if (fromEnvironment !== undefined && fromEnvironment !== "")
|
|
205
|
+
return { key: fromEnvironment, source: "environment" };
|
|
206
|
+
const fromFile = readDotenv(projectRoot)[KEY_NAME];
|
|
207
|
+
if (fromFile === undefined || fromFile === "")
|
|
208
|
+
return undefined;
|
|
209
|
+
if (existsSync(join(projectRoot, ".git"))) {
|
|
210
|
+
const ignored = spawnSync("git", ["check-ignore", "-q", ".env"], { cwd: projectRoot });
|
|
211
|
+
if (ignored.status === 1) {
|
|
212
|
+
throw new RoworkError("`.env` holds your Roblox API key, but git does not ignore that file.", {
|
|
213
|
+
hint: "Add a line `.env` to your .gitignore first: a key that gets committed must be considered leaked.",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { key: fromFile, source: ".env" };
|
|
218
|
+
}
|
|
219
|
+
/** A pasted key is one token: no spaces, no quotes, nothing that could break a `.env` line. */
|
|
220
|
+
export function looksLikeApiKey(text) {
|
|
221
|
+
return /^[A-Za-z0-9+/=_-]{16,}$/.test(text);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Stores the key in `.env`, in that order: first make sure git ignores `.env`, and only
|
|
225
|
+
* then write the key. Other lines of the file are kept. The file is readable by its owner only.
|
|
226
|
+
*/
|
|
227
|
+
export function saveApiKey(projectRoot, key) {
|
|
228
|
+
const gitignore = join(projectRoot, ".gitignore");
|
|
229
|
+
const current = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
|
|
230
|
+
if (!current.split(/\r?\n/).some((line) => line.trim() === ".env")) {
|
|
231
|
+
const separator = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
232
|
+
writeFileSync(gitignore, `${current}${separator}.env\n`, "utf8");
|
|
233
|
+
}
|
|
234
|
+
const file = join(projectRoot, ".env");
|
|
235
|
+
const lines = existsSync(file) ? readFileSync(file, "utf8").split(/\r?\n/) : [];
|
|
236
|
+
const entry = `${KEY_NAME}=${key}`;
|
|
237
|
+
const index = lines.findIndex((line) => new RegExp(`^\\s*(?:export\\s+)?${KEY_NAME}\\s*=`).test(line));
|
|
238
|
+
if (index >= 0) {
|
|
239
|
+
lines[index] = entry;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
243
|
+
lines.pop();
|
|
244
|
+
lines.push(entry);
|
|
245
|
+
}
|
|
246
|
+
writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
|
|
247
|
+
try {
|
|
248
|
+
chmodSync(file, 0o600);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Not every file system has permissions (Windows): the .gitignore entry is what protects it there.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export function assetsFolder(config) {
|
|
255
|
+
return config.assets?.folder ?? DEFAULT_FOLDER;
|
|
256
|
+
}
|
|
257
|
+
/** Bytes as a short human string. */
|
|
258
|
+
export function humanSize(bytes) {
|
|
259
|
+
if (bytes < 1024)
|
|
260
|
+
return `${bytes} B`;
|
|
261
|
+
if (bytes < 1024 * 1024)
|
|
262
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
263
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
264
|
+
}
|
|
265
|
+
export function statSize(path) {
|
|
266
|
+
return statSync(path).size;
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=assets.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Set in the background process, so it knows to clean up after itself. */
|
|
2
|
+
export declare const DAEMON_ENV = "ROWORK_DEV_DAEMON";
|
|
3
|
+
export interface DevRecord {
|
|
4
|
+
pid: number;
|
|
5
|
+
startedAt: string;
|
|
6
|
+
port: number;
|
|
7
|
+
/** In a terminal of its own, or detached with `dev -d`. Older records have none. */
|
|
8
|
+
mode?: "foreground" | "background";
|
|
9
|
+
}
|
|
10
|
+
export interface DashboardRecord {
|
|
11
|
+
pid: number;
|
|
12
|
+
port: number;
|
|
13
|
+
/** The address WITH its secret token: this file is as private as the token itself. */
|
|
14
|
+
url: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function runDirectory(projectRoot: string): string;
|
|
17
|
+
export declare const logFile: (root: string) => string;
|
|
18
|
+
export declare function isAlive(pid: number): boolean;
|
|
19
|
+
export declare function readRecord(projectRoot: string): DevRecord | undefined;
|
|
20
|
+
export declare function writeRecord(projectRoot: string, record: DevRecord): void;
|
|
21
|
+
export declare function clearRecord(projectRoot: string): void;
|
|
22
|
+
/** The background `rowork dev` of this project, if one is really running. */
|
|
23
|
+
export declare function runningRecord(projectRoot: string): DevRecord | undefined;
|
|
24
|
+
/** Last `lines` lines of the background log. */
|
|
25
|
+
export declare function tailLog(projectRoot: string, lines: number): string[];
|
|
26
|
+
/**
|
|
27
|
+
* Starts `rowork dev` detached from this terminal and returns its pid.
|
|
28
|
+
*
|
|
29
|
+
* A session and process group of its own on POSIX, so closing the terminal
|
|
30
|
+
* neither reaches it nor stops it; no console window on Windows.
|
|
31
|
+
*/
|
|
32
|
+
export declare function spawnBackground(projectRoot: string, script: string, args: string[]): number;
|
|
33
|
+
/** Stops the process and everything it started. Returns false if it had to be forced. */
|
|
34
|
+
export declare function stopProcess(pid: number): Promise<boolean>;
|
|
35
|
+
/**
|
|
36
|
+
* Remembers the dashboard that is running, so `rowork dev -d` can print its address
|
|
37
|
+
* and `rowork dashboard` can open it instead of starting a second one.
|
|
38
|
+
*
|
|
39
|
+
* The address holds the secret token, so the file is readable by its owner only
|
|
40
|
+
* (mode 0600; on Windows the user profile already is private) and lives in
|
|
41
|
+
* `.rowork/run/`, which the generated .gitignore excludes.
|
|
42
|
+
*/
|
|
43
|
+
export declare function writeDashboardRecord(root: string, record: DashboardRecord): void;
|
|
44
|
+
export declare function clearDashboardRecord(root: string): void;
|
|
45
|
+
/** The dashboard of this project, if its process is really alive. A stale record is removed. */
|
|
46
|
+
export declare function runningDashboard(root: string): DashboardRecord | undefined;
|
|
47
|
+
//# sourceMappingURL=background.d.ts.map
|