dotframe 0.1.9 → 0.2.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/CHANGELOG.md +15 -0
- package/cli/commands/config.ts +21 -3
- package/cli/commands/docker.ts +36 -19
- package/cli/commands/doctor.ts +24 -3
- package/cli/commands/online.ts +37 -6
- package/cli/commands/ship.ts +57 -1
- package/cli/ios.ts +58 -11
- package/cli/lib.ts +29 -1
- package/cli/main.ts +19 -9
- package/cli/native.ts +21 -12
- package/native/df_audio.c +12 -0
- package/native/df_native.c +67 -10
- package/native/df_ws_apple.m +197 -0
- package/native/df_ws_win.c +231 -0
- package/native/ffi.macos.json +28 -29
- package/native/ffi.windows.json +30 -30
- package/package.json +1 -1
- package/scripts/build-native.sh +6 -2
- package/skill-data/core/SKILL.md +3 -1
- package/skill-data/export-web/SKILL.md +1 -0
- package/skill-data/ios/SKILL.md +6 -1
- package/skill-data/macos/SKILL.md +7 -0
- package/skill-data/netplay/SKILL.md +5 -1
- package/src/draw2d.ts +7 -0
- package/src/gpu.ts +3 -0
- package/src/native/backend.ts +8 -10
- package/src/native/ffi.ts +9 -5
- package/src/native/relay.ts +95 -0
- package/src/native/run.ts +1 -0
- package/src/probe-web.ts +7 -0
- package/src/probe.ts +3 -3
- package/src/ui.ts +191 -0
- package/src/web/run.ts +38 -10
- package/templates/_base/main.ios.ts +1 -1
- package/templates/_base/main.native.ts +27 -2
- package/templates/_base/main.web.ts +3 -1
- package/templates/_base/src/setup.ts +22 -7
- package/tools/gen-library-glue.ts +10 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
From Craft Ones' asks (rounds 7 and 8).
|
|
6
|
+
|
|
7
|
+
- Native textures grow without a cap (it was 256, and iOS aborted with no message past it); `gpu.destroyTexture(texture)` frees one and the bind groups that sample it.
|
|
8
|
+
- iOS: build ios wraps init and frame so a TypeScript throw prints its message and stack before scriptc's trap aborts; `dotframe device logs <target>` copies the newest crash report from the device and summarizes it.
|
|
9
|
+
- Native online play: `dotframe/src/native/relay` (`connectRelayNative`, the same RelayLink as the web, polled per frame) over a WebSocket client in C (NSURLSession on macOS and iOS, WinHTTP on Windows), and `shareText` (share sheet on iOS, clipboard on desktop). `play --online --native` runs a web peer against the macOS build. Inside a scriptc executable, hostnames and TLS do not connect yet (reported to scriptc); IP addresses and localhost do.
|
|
10
|
+
- iOS library callbacks stay under scriptc's 32: the glue registers only the host functions the entry reaches, music and volume are one call, and networking is two.
|
|
11
|
+
- `dotframe/src/ui`: buttons and arrow rows laid out once for drawing and hit-testing, focus, taps from mouse and touch, and a release gate so the press that starts a match does not reach gameplay.
|
|
12
|
+
- Web: `run(window, setup, { fit: "window" })` fills and follows the browser window; `Draw2D.resize`; templates refit on shape changes and use it.
|
|
13
|
+
- `doctor` compile-checks each native target with scriptc (no C, no link), so patterns that only fail natively show up before building for the phone.
|
|
14
|
+
- `deploy init --site <dir> [--path /play/]` serves a static folder at / with the game at a subpath.
|
|
15
|
+
- `dotframe config set vendor <dir>` stores the vendor dir per machine (~/.dotframe/config.json).
|
|
16
|
+
- Fixed: the native templates trapped at startup since 0.1.7 (a setup function passed to run() with a narrower parameter type, and, since 0.1.9, the probe assigning globalThis). The probe no longer touches globalThis (`publishProbe` from `dotframe/src/probe-web` does, on the web), and CI now runs each template binary.
|
|
17
|
+
|
|
3
18
|
## 0.1.9
|
|
4
19
|
|
|
5
20
|
The rest of round 7, which missed the 0.1.8 merge.
|
package/cli/commands/config.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { CONFIG_FILE, CliError, type Ctx, formatJson, loadConfig, print } from "../lib";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { CONFIG_FILE, CliError, type Ctx, formatJson, home, loadConfig, loadUserConfig, print, saveUserConfig, USER_KEYS, userConfigPath } from "../lib";
|
|
4
4
|
|
|
5
5
|
// Dotted keys: targets.web.deploy.project
|
|
6
6
|
export async function configCmd(ctx: Ctx, args: string[]): Promise<void> {
|
|
7
7
|
const [op, key, value] = args;
|
|
8
|
+
// Machine paths go to ~/.dotframe/config.json, never into the repo.
|
|
9
|
+
if (key && (USER_KEYS as readonly string[]).includes(key)) {
|
|
10
|
+
const user = loadUserConfig();
|
|
11
|
+
if (op === "get") {
|
|
12
|
+
print(ctx, { key, value: user.vendor ?? null, file: userConfigPath() }, (): string => user.vendor ?? "(not set)");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (op !== "set" || value === undefined) throw new CliError("MISSING_ARG", `config set ${key} needs a value`, `dotframe config set ${key} ~/Programming/crafter-games/dotframe/vendor`);
|
|
16
|
+
const dir = resolve(home(value));
|
|
17
|
+
if (!existsSync(dir)) throw new CliError("BAD_ARG", `${dir} does not exist`, "point at a vendor dir that holds wgpu/ and SDL3-*/");
|
|
18
|
+
if (ctx.dryRun) {
|
|
19
|
+
print(ctx, { dryRun: true, key, value: dir, file: userConfigPath() }, (): string => `would set ${key} = ${dir} in ${userConfigPath()}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
saveUserConfig({ ...user, vendor: dir });
|
|
23
|
+
print(ctx, { key, value: dir, file: userConfigPath() }, (): string => `${key} = ${dir} (${userConfigPath()})`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
8
26
|
const config = loadConfig();
|
|
9
27
|
const file = join(config.root, CONFIG_FILE);
|
|
10
28
|
const raw = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
package/cli/commands/docker.ts
CHANGED
|
@@ -39,7 +39,13 @@ function versions(gameRoot: string, repo: string): Versions {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// game: the game's path inside the repo ("" at the root); context: the repo root, relative to deploy/.
|
|
42
|
-
|
|
42
|
+
// site: a static folder (relative to the game root) served at /, with the game at gamePath (for example "/play/").
|
|
43
|
+
interface Layout {
|
|
44
|
+
site?: string;
|
|
45
|
+
gamePath: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function files(config: Config, game: string, out: string, context: string, v: Versions, layout: Layout): Record<string, string> {
|
|
43
49
|
const requires = (config.requires ?? []).filter((t: string): boolean => t !== "bun");
|
|
44
50
|
const apt = requires.length > 0 ? `RUN apt-get update && apt-get install -y --no-install-recommends ${requires.join(" ")} && rm -rf /var/lib/apt/lists/*\n` : "";
|
|
45
51
|
const at = game === "" ? "." : game;
|
|
@@ -52,7 +58,8 @@ RUN cd ${at} && bun install --frozen-lockfile && bun node_modules/dotframe/cli/m
|
|
|
52
58
|
|
|
53
59
|
FROM nginx:1.27-alpine
|
|
54
60
|
COPY ${join(at, DIR, "nginx.conf")} /etc/nginx/conf.d/default.conf
|
|
55
|
-
COPY
|
|
61
|
+
${layout.site ? `COPY ${join(at, layout.site)} /usr/share/nginx/html
|
|
62
|
+
` : ""}COPY --from=build ${join("/app", at, out)} ${join("/usr/share/nginx/html", layout.gamePath)}
|
|
56
63
|
EXPOSE 80
|
|
57
64
|
`,
|
|
58
65
|
"Dockerfile.relay": `# Netplay relay for ${config.name}: dotframe relay serve. Generated by dotframe deploy init.
|
|
@@ -64,20 +71,7 @@ ENV PORT=8787
|
|
|
64
71
|
EXPOSE 8787
|
|
65
72
|
CMD ["bun", "node_modules/dotframe/cli/main.ts", "relay", "serve"]
|
|
66
73
|
`,
|
|
67
|
-
"nginx.conf":
|
|
68
|
-
listen 80;
|
|
69
|
-
server_name _;
|
|
70
|
-
root /usr/share/nginx/html;
|
|
71
|
-
index index.html;
|
|
72
|
-
gzip on;
|
|
73
|
-
gzip_types text/css application/javascript application/json image/svg+xml;
|
|
74
|
-
gzip_min_length 1024;
|
|
75
|
-
# The bundle name is content-hashed; index.html must never be cached (Discord caches aggressively).
|
|
76
|
-
location = /index.html { add_header Cache-Control "no-cache"; }
|
|
77
|
-
location / { try_files $uri $uri/ /index.html; }
|
|
78
|
-
location ~* \\.(png|mp3|js|json)$ { expires 7d; add_header Cache-Control "public"; }
|
|
79
|
-
}
|
|
80
|
-
`,
|
|
74
|
+
"nginx.conf": nginx(layout),
|
|
81
75
|
"compose.yaml": `# ${config.name}: the game at / and the netplay relay at /relay on the same host. In Dokploy, point the domain's
|
|
82
76
|
# / at service web (port 80) and /relay at service relay (port 8787).
|
|
83
77
|
services:
|
|
@@ -102,7 +96,28 @@ services:
|
|
|
102
96
|
};
|
|
103
97
|
}
|
|
104
98
|
|
|
105
|
-
|
|
99
|
+
function nginx(layout: Layout): string {
|
|
100
|
+
const sub = layout.gamePath !== "/";
|
|
101
|
+
const base = layout.gamePath.replace(/\/$/, "");
|
|
102
|
+
return `server {
|
|
103
|
+
listen 80;
|
|
104
|
+
server_name _;
|
|
105
|
+
root /usr/share/nginx/html;
|
|
106
|
+
index index.html;
|
|
107
|
+
gzip on;
|
|
108
|
+
gzip_types text/css application/javascript application/json image/svg+xml;
|
|
109
|
+
gzip_min_length 1024;
|
|
110
|
+
# Bundles are content-hashed; HTML must never be cached (Discord caches aggressively).
|
|
111
|
+
location ~ \\.html$ { add_header Cache-Control "no-cache"; }
|
|
112
|
+
${sub ? ` location = ${base} { return 301 ${base}/$is_args$args; }
|
|
113
|
+
location ${base}/ { try_files $uri $uri/ ${base}/index.html; }
|
|
114
|
+
` : ""} location / { try_files $uri $uri/ /index.html; }
|
|
115
|
+
location ~* \\.(png|mp3|js|json|css|woff2|webp|jpg|ico)$ { expires 7d; add_header Cache-Control "public"; }
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function deployInit(ctx: Ctx, name: string | undefined, provider: string | undefined, compose: string | undefined, site?: string, path?: string): Promise<void> {
|
|
106
121
|
if (!name) throw new CliError("MISSING_ARG", "deploy init needs a target", "dotframe deploy init web --provider dokploy", "export-web");
|
|
107
122
|
if (provider !== "dokploy") throw new CliError("UNSUPPORTED", `deploy init supports --provider dokploy (got ${provider ?? "none"}); vercel needs no files`, "dotframe deploy init web --provider dokploy", "export-web");
|
|
108
123
|
const config = loadConfig();
|
|
@@ -111,7 +126,9 @@ export async function deployInit(ctx: Ctx, name: string | undefined, provider: s
|
|
|
111
126
|
const repo = await gitRoot(ctx, config.root);
|
|
112
127
|
const game = relative(repo, config.root);
|
|
113
128
|
// compose.yaml sits in <game>/deploy; the web build context is the repo root, so workspace code is visible.
|
|
114
|
-
|
|
129
|
+
if (site && !existsSync(join(config.root, site))) throw new CliError("BAD_ARG", `--site ${site} does not exist under the game root`, "pass a folder with an index.html, relative to the game root", "export-web");
|
|
130
|
+
const gamePath = path ? `/${path.replace(/^\/+|\/+$/g, "")}/`.replace("//", "/") : site ? "/play/" : "/";
|
|
131
|
+
const generated = files(config, game, t.out, relative(join(config.root, DIR), repo) || ".", versions(config.root, repo), { site, gamePath });
|
|
115
132
|
const dir = join(config.root, DIR);
|
|
116
133
|
const written = Object.keys(generated).map((f: string): string => join(DIR, f));
|
|
117
134
|
const existing = written.filter((f: string): boolean => existsSync(join(config.root, f)));
|
|
@@ -125,7 +142,7 @@ export async function deployInit(ctx: Ctx, name: string | undefined, provider: s
|
|
|
125
142
|
// The repo-root .dockerignore is the one docker reads for a repo-root context.
|
|
126
143
|
if (!existsSync(join(repo, ".dockerignore"))) writeFileSync(join(repo, ".dockerignore"), generated[".dockerignore"]);
|
|
127
144
|
const raw = JSON.parse(readFileSync(join(config.root, CONFIG_FILE), "utf8")) as { targets: Record<string, { deploy?: unknown }> };
|
|
128
|
-
raw.targets[name].deploy = { provider: "dokploy", compose: compose ?? "", dir: DIR };
|
|
145
|
+
raw.targets[name].deploy = { provider: "dokploy", compose: compose ?? "", dir: DIR, ...(site ? { site } : {}), ...(gamePath !== "/" ? { path: gamePath } : {}) };
|
|
129
146
|
writeFileSync(join(config.root, CONFIG_FILE), `${formatJson(raw)}\n`);
|
|
130
147
|
print(ctx, { files: written, next: ["git add deploy dotframe.json && git commit && git push", "create the compose in Dokploy from this repo (compose path below)", `dotframe config set targets.${name}.deploy.compose '"<compose id>"'`] }, (): string =>
|
|
131
148
|
[`wrote ${written.join(", ")}`, `compose path for Dokploy: ${join(game, DIR, "compose.yaml")}`, `then: dotframe config set targets.${name}.deploy.compose '"<compose id>"' and dotframe deploy ${name}`].join("\n"),
|
package/cli/commands/doctor.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { type Config, type Ctx, findRoot, home, loadConfig, print, which } from "../lib";
|
|
3
|
+
import { CliError, type Config, type Ctx, findRoot, home, loadConfig, print, type Target, which } from "../lib";
|
|
4
4
|
import type { Draw2D } from "../../src/draw2d";
|
|
5
5
|
import type { Sim } from "../../src/sim";
|
|
6
|
-
import { importGraph, vendorFix, vendorStatus } from "../native";
|
|
7
|
-
import { iosPackFix, iosRuntimePack } from "../ios";
|
|
6
|
+
import { buildNative, importGraph, vendorFix, vendorStatus } from "../native";
|
|
7
|
+
import { buildIos, iosPackFix, iosRuntimePack } from "../ios";
|
|
8
8
|
import { dockerCheck } from "./docker";
|
|
9
9
|
import { firstDifference, flatten, headlessRun, loadSim } from "../simkit";
|
|
10
10
|
|
|
@@ -59,6 +59,7 @@ export async function doctor(ctx: Ctx, fix: boolean, docker = false): Promise<vo
|
|
|
59
59
|
}
|
|
60
60
|
for (const [name, t] of Object.entries(config.targets)) {
|
|
61
61
|
if (!t.native) continue;
|
|
62
|
+
checks.push(await nativeCheck(ctx, config, name, t.native));
|
|
62
63
|
const v = vendorStatus(t.native.platform);
|
|
63
64
|
checks.push({ check: `vendor:${name}`, ok: v.missing.length === 0, detail: v.missing.length === 0 ? `${v.dir} (${v.sdl})` : `missing in ${v.dir}: ${v.missing.join(", ")}`, fix: v.missing.length === 0 ? "" : vendorFix(t.native.platform), skill: t.native.platform });
|
|
64
65
|
checks.push(tool("scriptc", "native builds", "npm i -g scriptc", t.native.platform));
|
|
@@ -196,3 +197,23 @@ function mathCheck(simFile: string): Check {
|
|
|
196
197
|
fix: "use dotframe/src/detmath (dsin, dcos, datan2, dexp, dpow, ...) in simulation code; mark render-only lines with // dotframe-allow-math, or a render-only file with // dotframe-allow-math-file",
|
|
197
198
|
};
|
|
198
199
|
}
|
|
200
|
+
|
|
201
|
+
// Compiles a native target's TypeScript the way the real build does (staging plus scriptc; for iOS the library),
|
|
202
|
+
// without C or linking: as const tuples and other patterns that work on the web fail here, not on the phone.
|
|
203
|
+
async function nativeCheck(ctx: Ctx, config: Config, name: string, native: NonNullable<Target["native"]>): Promise<Check> {
|
|
204
|
+
const base = { check: `native:${name}`, skill: native.platform };
|
|
205
|
+
if (!which("scriptc")) return { ...base, ok: false, detail: "scriptc not found", fix: "npm i -g scriptc" };
|
|
206
|
+
const quiet = { ...ctx, json: true, dryRun: false };
|
|
207
|
+
try {
|
|
208
|
+
const started = performance.now();
|
|
209
|
+
if (native.platform === "ios") await buildIos(quiet, config.root, config.name, name, native, true);
|
|
210
|
+
else await buildNative(quiet, config.root, config.name, name, native, true);
|
|
211
|
+
return { ...base, ok: true, detail: `${native.entry} compiles with scriptc (${Math.round(performance.now() - started)} ms)`, fix: "" };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!(error instanceof CliError)) throw error;
|
|
214
|
+
// Missing tools or packs have their own checks; this one is about the code.
|
|
215
|
+
if (error.code !== "STEP_FAILED") return { ...base, ok: false, detail: error.message.split("\n")[0], fix: error.fix };
|
|
216
|
+
const first = error.message.split("\n").find((l: string): boolean => /error SC\d+/.test(l)) ?? error.message.split("\n")[0];
|
|
217
|
+
return { ...base, ok: false, detail: `scriptc rejects ${native.entry}: ${first.replace(/^.*?\.dotframe\/check\/[^/]+\/(game|tree)\//, "")}`, fix: `read ${error.log}; the macos skill lists what scriptc rejects` };
|
|
218
|
+
}
|
|
219
|
+
}
|
package/cli/commands/online.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { CliError, type Ctx, exec, loadConfig, num, print, runSteps, target, which } from "../lib";
|
|
4
|
+
import { buildNative, type NativeTarget } from "../native";
|
|
4
5
|
import { startRelay } from "../relay";
|
|
5
6
|
|
|
6
7
|
interface Probe {
|
|
@@ -13,7 +14,7 @@ interface Probe {
|
|
|
13
14
|
// Two browsers play one online match through a local relay, each with a scripted masher, and their checksums at
|
|
14
15
|
// every 30th confirmed frame must agree. The game's web entry honors ?room=, ?relay= and ?mash=, and publishes
|
|
15
16
|
// globalThis.__dotframe = { frame, confirmed, status, sums } (the templates do; see the netplay skill).
|
|
16
|
-
export async function playOnline(ctx: Ctx, room: string | undefined, args: { frames?: string; out?: string; seeds?: string }): Promise<void> {
|
|
17
|
+
export async function playOnline(ctx: Ctx, room: string | undefined, args: { frames?: string; out?: string; seeds?: string; native?: boolean }): Promise<void> {
|
|
17
18
|
const skill = "netplay";
|
|
18
19
|
const config = loadConfig();
|
|
19
20
|
const t = target(config, "web");
|
|
@@ -27,6 +28,15 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
|
|
|
27
28
|
const name = room ?? `play-${Date.now().toString(36)}`;
|
|
28
29
|
const outDir = resolve(process.cwd(), args.out ?? ".dotframe/play");
|
|
29
30
|
await runSteps({ ...ctx, json: true }, config.root, t.steps ?? [], "export-web");
|
|
31
|
+
// --native: peer 1 is the macOS build, launched with DOTFRAME_ROOM, DOTFRAME_RELAY, DOTFRAME_MASH and DOTFRAME_PROBE.
|
|
32
|
+
let nativeBinary = "";
|
|
33
|
+
if (args.native) {
|
|
34
|
+
const entry = Object.entries(config.targets).find(([, target]) => target.native?.platform === "macos");
|
|
35
|
+
if (!entry) throw new CliError("UNKNOWN_TARGET", "play --native needs a target with native.platform macos", "add \"macos\": {\"native\": {\"platform\": \"macos\", \"entry\": \"main.native.ts\"}}", skill);
|
|
36
|
+
const [nativeName, nativeTarget] = entry;
|
|
37
|
+
const built = await buildNative({ ...ctx, json: true }, config.root, config.name, nativeName, nativeTarget.native as NativeTarget);
|
|
38
|
+
nativeBinary = built.binary;
|
|
39
|
+
}
|
|
30
40
|
const web = resolve(config.root, t.out);
|
|
31
41
|
const relay = startRelay(0);
|
|
32
42
|
const server = Bun.serve({
|
|
@@ -37,7 +47,9 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
|
|
|
37
47
|
return existsSync(file) ? new Response(Bun.file(file)) : new Response("not found", { status: 404 });
|
|
38
48
|
},
|
|
39
49
|
});
|
|
40
|
-
const sessions = ["dotframe-play-0", "dotframe-play-1"];
|
|
50
|
+
const sessions = args.native ? ["dotframe-play-0"] : ["dotframe-play-0", "dotframe-play-1"];
|
|
51
|
+
const probeFile = join(outDir, "native-probe.json");
|
|
52
|
+
let nativeProcess: ReturnType<typeof Bun.spawn> | null = null;
|
|
41
53
|
const ab = (session: string, ...a: string[]) => exec({ ...ctx, json: true }, config.root, { label: `agent-browser ${a[0]}`, argv: ["agent-browser", "--session", session, ...a] });
|
|
42
54
|
const read = async (session: string): Promise<Probe | null> => {
|
|
43
55
|
const r = await ab(session, "eval", "JSON.stringify(globalThis.__dotframe ?? null)");
|
|
@@ -54,13 +66,30 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
|
|
|
54
66
|
const opened = await ab(session, "open", url);
|
|
55
67
|
if (opened.code !== 0) throw new CliError("BROWSER_FAILED", opened.tail, "agent-browser install", skill);
|
|
56
68
|
}
|
|
69
|
+
if (args.native) {
|
|
70
|
+
mkdirSync(outDir, { recursive: true });
|
|
71
|
+
rmSync(probeFile, { force: true });
|
|
72
|
+
nativeProcess = Bun.spawn([nativeBinary], {
|
|
73
|
+
cwd: config.root,
|
|
74
|
+
env: { ...process.env, DOTFRAME_ROOM: name, DOTFRAME_RELAY: `ws://localhost:${relay.port}`, DOTFRAME_MASH: String(seeds[1]), DOTFRAME_PROBE: probeFile },
|
|
75
|
+
stdout: "ignore",
|
|
76
|
+
stderr: "ignore",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const readNative = (): Probe | null => {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(readFileSync(probeFile, "utf8")) as Probe;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
57
86
|
let probes: (Probe | null)[] = [null, null];
|
|
58
87
|
const started = Date.now();
|
|
59
88
|
// Wait until both peers confirm `frames` frames, or stop making progress.
|
|
60
89
|
let last = -1;
|
|
61
90
|
let lastChange = Date.now();
|
|
62
91
|
for (;;) {
|
|
63
|
-
probes = [await read(sessions[0]), await read(sessions[1])];
|
|
92
|
+
probes = [await read(sessions[0]), args.native ? readNative() : await read(sessions[1])];
|
|
64
93
|
const confirmed = Math.min(...probes.map((p) => p?.confirmed ?? 0));
|
|
65
94
|
if (confirmed >= frames) break;
|
|
66
95
|
if (confirmed !== last) {
|
|
@@ -76,20 +105,22 @@ export async function playOnline(ctx: Ctx, room: string | undefined, args: { fra
|
|
|
76
105
|
const compared = Object.keys(a.sums).filter((f: string): boolean => b.sums[f] !== undefined).map(Number).sort((x, y) => x - y);
|
|
77
106
|
const mismatch = compared.find((f: number): boolean => a.sums[f] !== b.sums[f]);
|
|
78
107
|
const shots = sessions.map((_s, i) => join(outDir, `peer-${i}.png`));
|
|
108
|
+
// The native peer's window is not captured; its checksums are the comparison.
|
|
79
109
|
mkdirSync(outDir, { recursive: true });
|
|
80
110
|
for (const [i, session] of sessions.entries()) {
|
|
81
111
|
const shot = await ab(session, "screenshot", shots[i]);
|
|
82
112
|
if (shot.code !== 0 || !existsSync(shots[i])) throw new CliError("BROWSER_FAILED", `screenshot of peer ${i} failed: ${shot.tail}`, "", skill);
|
|
83
113
|
}
|
|
84
114
|
const ok = mismatch === undefined && compared.length > 0;
|
|
85
|
-
print(ctx, { ok, room: name, seeds, frames: Math.min(a.confirmed, b.confirmed), compared: compared.length, firstMismatch: mismatch ?? null, seconds: Math.round((Date.now() - started) / 1000), screenshots: shots }, (): string =>
|
|
115
|
+
print(ctx, { ok, room: name, seeds, peers: args.native ? ["web", "native macOS"] : ["web", "web"], frames: Math.min(a.confirmed, b.confirmed), compared: compared.length, firstMismatch: mismatch ?? null, seconds: Math.round((Date.now() - started) / 1000), screenshots: shots }, (): string =>
|
|
86
116
|
[
|
|
87
|
-
ok ? `in sync: ${compared.length} checksums compared over ${Math.min(a.confirmed, b.confirmed)} confirmed frames` : mismatch !== undefined ? `DESYNC at frame ${mismatch}` : "no checksums to compare",
|
|
117
|
+
ok ? `in sync${args.native ? " (web vs native macOS)" : ""}: ${compared.length} checksums compared over ${Math.min(a.confirmed, b.confirmed)} confirmed frames` : mismatch !== undefined ? `DESYNC at frame ${mismatch}` : "no checksums to compare",
|
|
88
118
|
`screenshots: ${shots.join(", ")} (open them and look)`,
|
|
89
119
|
].join("\n"),
|
|
90
120
|
);
|
|
91
121
|
if (!ok) process.exit(1);
|
|
92
122
|
} finally {
|
|
123
|
+
nativeProcess?.kill();
|
|
93
124
|
for (const session of sessions) await ab(session, "close");
|
|
94
125
|
server.stop(true);
|
|
95
126
|
relay.stop(true);
|
package/cli/commands/ship.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { buildIos } from "../ios";
|
|
4
4
|
import { deployDokploy } from "./docker";
|
|
@@ -142,3 +142,59 @@ export async function vendor(ctx: Ctx, platform: string | undefined): Promise<vo
|
|
|
142
142
|
if (after.missing.length > 0) throw new CliError("VENDOR_MISSING", `vendor.sh finished but ${after.missing.join(", ")} is still missing`, "", "macos");
|
|
143
143
|
print(ctx, { platform: p, dir, ready: true }, (): string => `vendor for ${p} ready in ${dir}`);
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
interface CrashFile {
|
|
147
|
+
name: string;
|
|
148
|
+
metadata: { lastModDate: string };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The newest crash report of a target's app on the device, summarized: exception, termination, and the crashed
|
|
152
|
+
// thread's frames. iOS keeps reports as <Process>-<date>.ips under systemCrashLogs. Read-only on the device.
|
|
153
|
+
export async function deviceLogs(ctx: Ctx, name: string): Promise<void> {
|
|
154
|
+
const config = loadConfig();
|
|
155
|
+
const t = target(config, name);
|
|
156
|
+
if (!t.device) throw new CliError("NO_DEVICE", `target "${name}" needs "device" in dotframe.json`, "xcrun devicectl list devices", "ios");
|
|
157
|
+
const builtApp = existsSync(join(config.root, "dist", name)) ? readdirSync(join(config.root, "dist", name)).find((f: string): boolean => f.endsWith(".app")) : undefined;
|
|
158
|
+
const processName = (t.app ? t.app.split("/").pop() : builtApp)?.replace(/\.app$/, "");
|
|
159
|
+
if (!processName) throw new CliError("NOT_BUILT", "no built app to name the process", `dotframe build ${name}`, "ios");
|
|
160
|
+
const logDir = join(config.root, ".dotframe", "logs");
|
|
161
|
+
mkdirSync(logDir, { recursive: true });
|
|
162
|
+
const listing = join(logDir, "device-crashes.json");
|
|
163
|
+
const list = await exec({ ...ctx, json: true }, config.root, { label: "devicectl files", argv: ["xcrun", "devicectl", "device", "info", "files", "--device", t.device, "--domain-type", "systemCrashLogs", "--json-output", listing] });
|
|
164
|
+
if (list.code !== 0) throw new CliError("DEVICE_UNAVAILABLE", list.tail, "connect and unlock the device; xcrun devicectl list devices", "ios");
|
|
165
|
+
const files = (JSON.parse(readFileSync(listing, "utf8")) as { result: { files: CrashFile[] } }).result.files;
|
|
166
|
+
const reports = files
|
|
167
|
+
.filter((f: CrashFile): boolean => f.name.startsWith(`${processName}-`) && f.name.endsWith(".ips"))
|
|
168
|
+
.sort((a: CrashFile, b: CrashFile): number => b.metadata.lastModDate.localeCompare(a.metadata.lastModDate));
|
|
169
|
+
if (reports.length === 0) {
|
|
170
|
+
print(ctx, { process: processName, crashes: 0 }, (): string => `no crash reports for ${processName} on the device`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const newest = reports[0];
|
|
174
|
+
const local = join(logDir, newest.name);
|
|
175
|
+
const copy = await exec({ ...ctx, json: true }, config.root, { label: "devicectl copy", argv: ["xcrun", "devicectl", "device", "copy", "from", "--device", t.device, "--domain-type", "systemCrashLogs", "--source", newest.name, "--destination", local] });
|
|
176
|
+
if (copy.code !== 0) throw new CliError("DEVICE_UNAVAILABLE", copy.tail, "", "ios");
|
|
177
|
+
const [, body] = readFileSync(local, "utf8").split(/\n(.*)/s);
|
|
178
|
+
const report = JSON.parse(body) as {
|
|
179
|
+
exception?: { type?: string; signal?: string };
|
|
180
|
+
termination?: { indicator?: string };
|
|
181
|
+
faultingThread?: number;
|
|
182
|
+
threads?: { frames: { symbol?: string; imageIndex: number }[] }[];
|
|
183
|
+
usedImages?: { name?: string }[];
|
|
184
|
+
};
|
|
185
|
+
const frames = (report.threads?.[report.faultingThread ?? 0]?.frames ?? [])
|
|
186
|
+
.slice(0, 12)
|
|
187
|
+
.map((f) => `${report.usedImages?.[f.imageIndex]?.name ?? "?"} ${f.symbol ?? "?"}`);
|
|
188
|
+
const summary = {
|
|
189
|
+
process: processName,
|
|
190
|
+
report: local,
|
|
191
|
+
when: newest.metadata.lastModDate,
|
|
192
|
+
crashes: reports.length,
|
|
193
|
+
exception: `${report.exception?.type ?? "?"} ${report.exception?.signal ?? ""}`.trim(),
|
|
194
|
+
termination: report.termination?.indicator ?? null,
|
|
195
|
+
frames,
|
|
196
|
+
};
|
|
197
|
+
print(ctx, summary, (): string =>
|
|
198
|
+
[`${processName} crashed ${newest.metadata.lastModDate} (${reports.length} reports on the device)`, `${summary.exception}${summary.termination ? `, ${summary.termination}` : ""}`, ...frames.map((f) => ` ${f}`), `full report: ${local}`, "a dotframe: ... threw line in the device log names a TypeScript error (build ios wraps init and frame)"].join("\n"),
|
|
199
|
+
);
|
|
200
|
+
}
|
package/cli/ios.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { CliError, type Ctx, exec, print, type RunResult, which } from "./lib";
|
|
5
|
-
import { ENGINE, sdlLibrary, stageGame, vendorFix, vendorStatus } from "./native";
|
|
5
|
+
import { ENGINE, importGraph, sdlLibrary, stageGame, vendorFix, vendorStatus } from "./native";
|
|
6
6
|
|
|
7
7
|
export interface IosTarget {
|
|
8
8
|
platform: "ios";
|
|
@@ -158,6 +158,7 @@ targets:
|
|
|
158
158
|
- path: ${o.id}_glue.c
|
|
159
159
|
- path: ${join(ENGINE, "native", "df_native.c")}
|
|
160
160
|
- path: ${join(ENGINE, "native", "df_audio.c")}
|
|
161
|
+
- path: ${join(ENGINE, "native", "df_ws_apple.m")}
|
|
161
162
|
- path: game
|
|
162
163
|
type: folder
|
|
163
164
|
buildPhase: resources
|
|
@@ -178,6 +179,7 @@ ${o.icon ? " ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon\n" : ""}
|
|
|
178
179
|
- ${join(o.vendor, "wgpu", "ios", "lib", "libwgpu_native.a")}
|
|
179
180
|
- -lc++
|
|
180
181
|
- -liconv
|
|
182
|
+
CLANG_ENABLE_OBJC_ARC: YES
|
|
181
183
|
GCC_WARN_INHIBIT_ALL_WARNINGS: YES
|
|
182
184
|
ENABLE_BITCODE: NO
|
|
183
185
|
dependencies:
|
|
@@ -185,8 +187,20 @@ ${frameworks.map((f) => ` - sdk: ${f}.framework`).join("\n")}
|
|
|
185
187
|
`;
|
|
186
188
|
}
|
|
187
189
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
+
// check: stop after the scriptc library (library mode is stricter than an executable build), for doctor.
|
|
191
|
+
// The df* host functions the code reachable from the entry calls (engine files included, since staged imports are
|
|
192
|
+
// relative): only those become library callbacks, because scriptc caps them at 32.
|
|
193
|
+
function usedHostFunctions(entry: string): string[] {
|
|
194
|
+
const used = new Set<string>();
|
|
195
|
+
for (const file of importGraph(entry).files) {
|
|
196
|
+
if (!file.endsWith(".ts") || file.endsWith(`${sep}ffi.ts`)) continue;
|
|
197
|
+
for (const m of readFileSync(file, "utf8").matchAll(/\b(df[A-Z]\w*)\(/g)) used.add(m[1]);
|
|
198
|
+
}
|
|
199
|
+
return [...used].sort();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function buildIos(ctx: Ctx, root: string, gameName: string, targetName: string, t: IosTarget, check = false): Promise<{ app: string; log: string; steps: RunResult[] }> {
|
|
203
|
+
for (const tool of check ? ["scriptc"] : ["scriptc", "xcodegen", "xcodebuild", "sips"]) {
|
|
190
204
|
if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, tool === "xcodegen" ? "brew install xcodegen" : tool === "scriptc" ? "npm i -g scriptc" : "install Xcode", SKILL);
|
|
191
205
|
}
|
|
192
206
|
for (const key of ["entry", "bundleId", "team"] as const) {
|
|
@@ -194,7 +208,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
|
|
|
194
208
|
}
|
|
195
209
|
if (!existsSync(join(root, t.entry))) throw new CliError("ENTRY_MISSING", `iOS entry ${t.entry} does not exist`, "add it (see the ios skill: export init and frame)", SKILL);
|
|
196
210
|
const vendor = vendorStatus("ios");
|
|
197
|
-
if (vendor.missing.length > 0) throw new CliError("VENDOR_MISSING", `iOS needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix("ios"), SKILL);
|
|
211
|
+
if (vendor.missing.length > 0 && !check) throw new CliError("VENDOR_MISSING", `iOS needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix("ios"), SKILL);
|
|
198
212
|
const pack = await iosRuntimePack(ctx, root);
|
|
199
213
|
if (!pack.path) {
|
|
200
214
|
throw new CliError("TOOL_MISSING", pack.found ? `scriptc iOS runtime pack is ${pack.found}, scriptc is ${pack.want}` : `scriptc iOS runtime pack ${pack.want} not found`, iosPackFix(pack.want), SKILL);
|
|
@@ -203,11 +217,11 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
|
|
|
203
217
|
const display = t.displayName ?? gameName;
|
|
204
218
|
const id = identifier(gameName);
|
|
205
219
|
const name = scheme(display);
|
|
206
|
-
const stage = join(root, ".dotframe", "native", targetName);
|
|
220
|
+
const stage = join(root, ".dotframe", check ? "check" : "native", targetName);
|
|
207
221
|
const out = join(root, "dist", targetName);
|
|
208
222
|
const app = join(out, `${name}.app`);
|
|
209
223
|
const logDir = join(root, ".dotframe", "logs");
|
|
210
|
-
const log = join(logDir,
|
|
224
|
+
const log = join(logDir, `${check ? "check" : "build"}-${targetName}.log`);
|
|
211
225
|
if (ctx.dryRun) {
|
|
212
226
|
print(ctx, { dryRun: true, platform: "ios", stage, app, scheme: name, bundleId: t.bundleId, team: t.team, runtimePack: pack.path }, (): string => `would stage ${t.entry} in ${stage} and build ${app}`);
|
|
213
227
|
return { app, log, steps: [] };
|
|
@@ -218,9 +232,41 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
|
|
|
218
232
|
const engineStage = join(stage, "dotframe");
|
|
219
233
|
cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
|
|
220
234
|
const gameStage = stageGame(root, t.entry, join(stage, "tree"), engineStage);
|
|
235
|
+
// A throw in library mode becomes a trap that aborts with no message (SIGABRT in SDL_AppInit). The wrapper prints
|
|
236
|
+
// the error and its stack to stderr, which the device log keeps, before rethrowing.
|
|
237
|
+
const wrapper = join(gameStage, "dotframe-entry.ts");
|
|
238
|
+
const gameEntry = `./${t.entry.replace(/\.ts$/, "")}`;
|
|
239
|
+
writeFileSync(
|
|
240
|
+
wrapper,
|
|
241
|
+
`import { frame as gameFrame, init as gameInit } from ${JSON.stringify(gameEntry)};
|
|
242
|
+
|
|
243
|
+
function report(where: string, error: unknown): void {
|
|
244
|
+
const detail = error instanceof Error ? \`\${error.message}\\n\${error.stack ?? ""}\` : String(error);
|
|
245
|
+
console.error(\`dotframe: \${where} threw: \${detail}\`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function init(base: string): void {
|
|
249
|
+
try {
|
|
250
|
+
gameInit(base);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
report("init", error);
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function frame(time: number): boolean {
|
|
258
|
+
try {
|
|
259
|
+
return gameFrame(time);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
report("frame", error);
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
`,
|
|
266
|
+
);
|
|
221
267
|
writeFileSync(
|
|
222
268
|
join(stage, "app.json"),
|
|
223
|
-
JSON.stringify({ name: id, entry: relative(stage,
|
|
269
|
+
JSON.stringify({ name: id, entry: relative(stage, wrapper), exports: [{ export: "init", params: ["string"], returns: "void" }, { export: "frame", params: ["f64"], returns: "bool" }] }, null, 2),
|
|
224
270
|
);
|
|
225
271
|
writeFileSync(join(stage, "host", "main.c"), hostC(id, display));
|
|
226
272
|
writeFileSync(join(stage, "host", "Info.plist"), infoPlist(display, t.orientation ?? "landscape"));
|
|
@@ -232,7 +278,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
|
|
|
232
278
|
cpSync(join(ENGINE, "assets", "fonts"), join(bundle, "dotframe", "assets", "fonts"), { recursive: true });
|
|
233
279
|
|
|
234
280
|
const steps: { label: string; argv: string[]; cwd?: string; env?: Record<string, string> }[] = [];
|
|
235
|
-
if (t.icon) {
|
|
281
|
+
if (t.icon && !check) {
|
|
236
282
|
const icons = join(stage, "Assets.xcassets", "AppIcon.appiconset");
|
|
237
283
|
mkdirSync(icons, { recursive: true });
|
|
238
284
|
writeFileSync(join(stage, "Assets.xcassets", "Contents.json"), '{"info":{"author":"xcode","version":1}}\n');
|
|
@@ -240,7 +286,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
|
|
|
240
286
|
steps.push({ label: "icon", argv: ["sips", "-z", "1024", "1024", resolve(root, t.icon), "--out", join(icons, "icon-1024.png")] });
|
|
241
287
|
}
|
|
242
288
|
steps.push(
|
|
243
|
-
{ label: "library glue", argv: ["bun", join(ENGINE, "tools", "gen-library-glue.ts"), join(stage, "app.json"), stage] },
|
|
289
|
+
{ label: "library glue", argv: ["bun", join(ENGINE, "tools", "gen-library-glue.ts"), join(stage, "app.json"), stage, join(ENGINE, "native", "ffi.macos.json"), usedHostFunctions(wrapper).join(",")] },
|
|
244
290
|
{ label: "scriptc library", argv: ["scriptc", "build", "--lib", "--profile", `${id}.profile.json`], cwd: stage, env: { SCRIPTC_TARGET: "aarch64-apple-ios", SCRIPTC_RUNTIME_PACK: pack.path } },
|
|
245
291
|
);
|
|
246
292
|
const results: RunResult[] = [];
|
|
@@ -257,6 +303,7 @@ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetN
|
|
|
257
303
|
}
|
|
258
304
|
};
|
|
259
305
|
for (const step of steps) await run(step);
|
|
306
|
+
if (check) return { app, log, steps: results };
|
|
260
307
|
const lib = readdirSync(join(stage, ".scriptc")).find((f: string): boolean => f.endsWith(".lib.a"));
|
|
261
308
|
if (!lib) throw new CliError("STEP_FAILED", "scriptc produced no .lib.a", `read ${log}`, SKILL, 1, log);
|
|
262
309
|
writeFileSync(join(stage, "project.yml"), projectYml({ name, id, bundleId: t.bundleId, team: t.team, vendor: vendor.dir, sdl: vendor.sdl ?? "", lib: join(stage, ".scriptc", lib), icon: !!t.icon }));
|
package/cli/lib.ts
CHANGED
|
@@ -57,7 +57,7 @@ export interface Target {
|
|
|
57
57
|
out?: string;
|
|
58
58
|
app?: string;
|
|
59
59
|
device?: string;
|
|
60
|
-
deploy?: { provider: "vercel"; project: string; scope: string } | { provider: "dokploy"; compose: string; dir?: string };
|
|
60
|
+
deploy?: { provider: "vercel"; project: string; scope: string } | { provider: "dokploy"; compose: string; dir?: string; site?: string; path?: string };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export interface Config {
|
|
@@ -191,3 +191,31 @@ export function formatJson(value: unknown, indent = "", width = 100): string {
|
|
|
191
191
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
192
192
|
return `{\n${entries.map(([k, v]) => `${next}${JSON.stringify(k)}: ${formatJson(v, next, width)}`).join(",\n")}\n${indent}}`;
|
|
193
193
|
}
|
|
194
|
+
|
|
195
|
+
// Per-machine settings (paths that must not go into a repo), in ~/.dotframe/config.json.
|
|
196
|
+
export interface UserConfig {
|
|
197
|
+
vendor?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function userConfigPath(): string {
|
|
201
|
+
return join(process.env.HOME ?? "", ".dotframe", "config.json");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function loadUserConfig(): UserConfig {
|
|
205
|
+
const path = userConfigPath();
|
|
206
|
+
if (!existsSync(path)) return {};
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(readFileSync(path, "utf8")) as UserConfig;
|
|
209
|
+
} catch {
|
|
210
|
+
throw new CliError("BAD_CONFIG", `${path} is not valid JSON`, `fix or delete ${path}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function saveUserConfig(config: UserConfig): void {
|
|
215
|
+
const path = userConfigPath();
|
|
216
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
217
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Keys stored per machine rather than in dotframe.json.
|
|
221
|
+
export const USER_KEYS = ["vendor"] as const;
|