lecodes-cli 0.8.1 → 0.9.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/dist/index.js +524 -144
- package/package.json +3 -3
- package/runtime/sdk/inject.ts +1 -1
- package/runtime/sdk/ui/UI.ts +1 -1
- package/runtime/sdk/ui/UINode.ts +7 -6
- package/runtime/sdk/ui/UIPager.ts +71 -3
- package/runtime/sdk/ui/UIScreen.ts +7 -57
- package/runtime/sdk/ui/UIScrollable.ts +19 -6
- package/runtime/sdk/ui/UIVirtualizedList.ts +12 -2
- package/runtime/sdk/ui/presentable.ts +2 -1
- package/runtime/sdk/ui/router.ts +4 -2
- package/runtime/sdk/ui/theme.ts +34 -7
- package/runtime/sdk-types.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2007,7 +2007,7 @@ var require_main = __commonJS((exports, module) => {
|
|
|
2007
2007
|
// package.json
|
|
2008
2008
|
var package_default = {
|
|
2009
2009
|
name: "lecodes-cli",
|
|
2010
|
-
version: "0.
|
|
2010
|
+
version: "0.9.0",
|
|
2011
2011
|
description: "Clone, edit and push LeCodes projects from your machine.",
|
|
2012
2012
|
type: "module",
|
|
2013
2013
|
license: "MIT",
|
|
@@ -2037,11 +2037,11 @@ var package_default = {
|
|
|
2037
2037
|
prepublishOnly: "bun run sync-peers && bun run build"
|
|
2038
2038
|
},
|
|
2039
2039
|
dependencies: {
|
|
2040
|
-
"@letary/chisel": "^0.4.
|
|
2040
|
+
"@letary/chisel": "^0.4.2"
|
|
2041
2041
|
},
|
|
2042
2042
|
peerDependencies: {
|
|
2043
2043
|
"lecodes-design": "^0.3.2",
|
|
2044
|
-
"lecodes-renderer": "^0.
|
|
2044
|
+
"lecodes-renderer": "^0.7.0",
|
|
2045
2045
|
"lecodes-3d-editor": "^0.1.0"
|
|
2046
2046
|
},
|
|
2047
2047
|
peerDependenciesMeta: {
|
|
@@ -2203,9 +2203,42 @@ var writeManifest = (root, manifest) => {
|
|
|
2203
2203
|
writeFileSync(manifestPath(root), JSON.stringify(manifest, null, 2));
|
|
2204
2204
|
};
|
|
2205
2205
|
|
|
2206
|
-
// src/
|
|
2207
|
-
import {
|
|
2206
|
+
// src/config.ts
|
|
2207
|
+
import { homedir } from "node:os";
|
|
2208
2208
|
import { join as join2 } from "node:path";
|
|
2209
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2210
|
+
var DEFAULT_API = "https://le.codes";
|
|
2211
|
+
var CONFIG_DIR = join2(homedir(), ".lecodes");
|
|
2212
|
+
var CONFIG_FILE = join2(CONFIG_DIR, "config.json");
|
|
2213
|
+
var loadConfig = () => {
|
|
2214
|
+
let stored = {};
|
|
2215
|
+
try {
|
|
2216
|
+
stored = JSON.parse(readFileSync2(CONFIG_FILE, "utf8"));
|
|
2217
|
+
} catch {}
|
|
2218
|
+
return {
|
|
2219
|
+
apiUrl: process.env.LECODES_API ?? stored.apiUrl,
|
|
2220
|
+
token: process.env.LECODES_TOKEN ?? stored.token
|
|
2221
|
+
};
|
|
2222
|
+
};
|
|
2223
|
+
var saveConfig = (config) => {
|
|
2224
|
+
mkdirSync2(CONFIG_DIR, { recursive: true });
|
|
2225
|
+
writeFileSync2(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
2226
|
+
};
|
|
2227
|
+
var normalizeApiUrl = (url) => url.trim().replace(/\/+$/, "");
|
|
2228
|
+
var requireToken = (config) => {
|
|
2229
|
+
if (!config.token)
|
|
2230
|
+
throw new CliError("Not logged in. Run `lecodes login` first.");
|
|
2231
|
+
return config.token;
|
|
2232
|
+
};
|
|
2233
|
+
var requireApiUrl = (config) => {
|
|
2234
|
+
if (!config.apiUrl)
|
|
2235
|
+
throw new CliError("No server URL configured. Run `lecodes login` first.");
|
|
2236
|
+
return normalizeApiUrl(config.apiUrl);
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
// src/ignore.ts
|
|
2240
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2241
|
+
import { join as join3 } from "node:path";
|
|
2209
2242
|
var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
|
|
2210
2243
|
var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
|
|
2211
2244
|
# Only untracked files are affected — assets already in the project stay tracked
|
|
@@ -2286,19 +2319,19 @@ var buildGlobMatcher = (patterns) => {
|
|
|
2286
2319
|
};
|
|
2287
2320
|
var loadIgnore = (root) => {
|
|
2288
2321
|
const lines = [...BUILTIN_PATTERNS];
|
|
2289
|
-
const file =
|
|
2322
|
+
const file = join3(root, ".lecodesignore");
|
|
2290
2323
|
if (existsSync2(file)) {
|
|
2291
2324
|
try {
|
|
2292
|
-
lines.push(...
|
|
2325
|
+
lines.push(...readFileSync3(file, "utf8").split(/\r?\n/));
|
|
2293
2326
|
} catch {}
|
|
2294
2327
|
}
|
|
2295
2328
|
const rules = lines.map(compile).filter((r) => r !== null);
|
|
2296
2329
|
return buildMatcher(rules);
|
|
2297
2330
|
};
|
|
2298
2331
|
var writeDefaultIgnore = (root) => {
|
|
2299
|
-
const file =
|
|
2332
|
+
const file = join3(root, ".lecodesignore");
|
|
2300
2333
|
if (!existsSync2(file))
|
|
2301
|
-
|
|
2334
|
+
writeFileSync3(file, DEFAULT_IGNORE_FILE);
|
|
2302
2335
|
};
|
|
2303
2336
|
|
|
2304
2337
|
// src/commands/compile.ts
|
|
@@ -2370,39 +2403,6 @@ var downloadBlob = async (apiUrl, src) => {
|
|
|
2370
2403
|
return new Uint8Array(await resp.arrayBuffer());
|
|
2371
2404
|
};
|
|
2372
2405
|
|
|
2373
|
-
// src/config.ts
|
|
2374
|
-
import { homedir } from "node:os";
|
|
2375
|
-
import { join as join3 } from "node:path";
|
|
2376
|
-
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2377
|
-
var DEFAULT_API = "https://le.codes";
|
|
2378
|
-
var CONFIG_DIR = join3(homedir(), ".lecodes");
|
|
2379
|
-
var CONFIG_FILE = join3(CONFIG_DIR, "config.json");
|
|
2380
|
-
var loadConfig = () => {
|
|
2381
|
-
let stored = {};
|
|
2382
|
-
try {
|
|
2383
|
-
stored = JSON.parse(readFileSync3(CONFIG_FILE, "utf8"));
|
|
2384
|
-
} catch {}
|
|
2385
|
-
return {
|
|
2386
|
-
apiUrl: process.env.LECODES_API ?? stored.apiUrl,
|
|
2387
|
-
token: process.env.LECODES_TOKEN ?? stored.token
|
|
2388
|
-
};
|
|
2389
|
-
};
|
|
2390
|
-
var saveConfig = (config) => {
|
|
2391
|
-
mkdirSync2(CONFIG_DIR, { recursive: true });
|
|
2392
|
-
writeFileSync3(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
2393
|
-
};
|
|
2394
|
-
var normalizeApiUrl = (url) => url.trim().replace(/\/+$/, "");
|
|
2395
|
-
var requireToken = (config) => {
|
|
2396
|
-
if (!config.token)
|
|
2397
|
-
throw new CliError("Not logged in. Run `lecodes login` first.");
|
|
2398
|
-
return config.token;
|
|
2399
|
-
};
|
|
2400
|
-
var requireApiUrl = (config) => {
|
|
2401
|
-
if (!config.apiUrl)
|
|
2402
|
-
throw new CliError("No server URL configured. Run `lecodes login` first.");
|
|
2403
|
-
return normalizeApiUrl(config.apiUrl);
|
|
2404
|
-
};
|
|
2405
|
-
|
|
2406
2406
|
// src/compile/collect.ts
|
|
2407
2407
|
import { readFileSync as readFileSync5 } from "node:fs";
|
|
2408
2408
|
import { basename } from "node:path";
|
|
@@ -3349,8 +3349,12 @@ class ViewController: LeCodesViewController {
|
|
|
3349
3349
|
|
|
3350
3350
|
registerBundledPlugins(in: engine)
|
|
3351
3351
|
|
|
3352
|
-
|
|
3353
|
-
|
|
3352
|
+
// OTA (native, no JS involved): start the check FIRST — even a bundle that crashes
|
|
3353
|
+
// on boot can never block its own fix — then run the newest local bundle (a
|
|
3354
|
+
// downloaded update, or the embedded app.js). New code applies on the next launch.
|
|
3355
|
+
// Configured via app.json "update" (→ Info.plist LeCodesUpdateURL); offline: no-op.
|
|
3356
|
+
LeCodesUpdater.checkForUpdate()
|
|
3357
|
+
guard let code = LeCodesUpdater.activeBundle() else {
|
|
3354
3358
|
print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
|
|
3355
3359
|
return
|
|
3356
3360
|
}
|
|
@@ -3358,13 +3362,18 @@ class ViewController: LeCodesViewController {
|
|
|
3358
3362
|
}
|
|
3359
3363
|
}
|
|
3360
3364
|
`;
|
|
3361
|
-
var infoPlistTemplate = (pluginKeys) => {
|
|
3365
|
+
var infoPlistTemplate = (pluginKeys, updateUrl) => {
|
|
3366
|
+
const xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3362
3367
|
const keys = Object.keys(pluginKeys).sort();
|
|
3363
3368
|
const pluginBlock = keys.length === 0 ? "" : `
|
|
3364
3369
|
<!-- lecodes:plugins — merged from installed plugin manifests by \`lecodes app sync\` -->
|
|
3365
3370
|
${keys.map((k) => ` <key>${k}</key>
|
|
3366
3371
|
<string>${pluginKeys[k]}</string>`).join(`
|
|
3367
3372
|
`)}`;
|
|
3373
|
+
const updateBlock = !updateUrl ? "" : `
|
|
3374
|
+
<!-- lecodes:update — native OTA source (app.json "update"); LeCodesUpdater checks it at launch -->
|
|
3375
|
+
<key>LeCodesUpdateURL</key>
|
|
3376
|
+
<string>${xml(updateUrl)}</string>`;
|
|
3368
3377
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3369
3378
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3370
3379
|
<plist version="1.0">
|
|
@@ -3385,7 +3394,7 @@ ${keys.map((k) => ` <key>${k}</key>
|
|
|
3385
3394
|
</dict>
|
|
3386
3395
|
</array>
|
|
3387
3396
|
</dict>
|
|
3388
|
-
</dict>${pluginBlock}
|
|
3397
|
+
</dict>${pluginBlock}${updateBlock}
|
|
3389
3398
|
</dict>
|
|
3390
3399
|
</plist>
|
|
3391
3400
|
`;
|
|
@@ -3579,6 +3588,16 @@ Sync links the smallest SDK product that covers what the compiled bundle actuall
|
|
|
3579
3588
|
project switches it automatically on the next sync; set \`ios.variant\` in \`../app.json\`
|
|
3580
3589
|
to pin one instead.
|
|
3581
3590
|
|
|
3591
|
+
## Over-the-air updates
|
|
3592
|
+
|
|
3593
|
+
With \`"update": true\` in \`../app.json\` (server-backed projects; or
|
|
3594
|
+
\`"update": {"url": "https://…/app.js"}\` for any static hosting), the app checks that URL
|
|
3595
|
+
at launch — natively, before any JS runs, so a broken bundle can never block its own fix.
|
|
3596
|
+
Unchanged code costs a ~0-byte 304; new code downloads in the background and runs on the
|
|
3597
|
+
NEXT launch. Offline it's a silent no-op — the app always launches from a local bundle.
|
|
3598
|
+
An App Store update always beats a cached OTA bundle. JS only: adding/removing native
|
|
3599
|
+
plugins still needs a store release.
|
|
3600
|
+
|
|
3582
3601
|
## Config
|
|
3583
3602
|
|
|
3584
3603
|
Identity lives in \`../app.json\` (name, version, plugins, ios.bundleId / teamId / variant /
|
|
@@ -3587,7 +3606,7 @@ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are ma
|
|
|
3587
3606
|
`;
|
|
3588
3607
|
|
|
3589
3608
|
// src/commands/app.ts
|
|
3590
|
-
var SDK_PACKAGE_VERSION = "1.
|
|
3609
|
+
var SDK_PACKAGE_VERSION = "1.2.0";
|
|
3591
3610
|
var SDK_VARIANTS = ["core", "2d", "3d", "full"];
|
|
3592
3611
|
var FIRST_PARTY_PLUGINS = {
|
|
3593
3612
|
camera: "letary/lecodes-plugins",
|
|
@@ -3809,6 +3828,25 @@ var ensureSdkResourcesBundle = async () => {
|
|
|
3809
3828
|
throw new CliError(`No LeCodesSDKResources.bundle inside the release asset ${url}`);
|
|
3810
3829
|
return bundle;
|
|
3811
3830
|
};
|
|
3831
|
+
var resolveUpdateUrl = (update, root) => {
|
|
3832
|
+
if (!update)
|
|
3833
|
+
return;
|
|
3834
|
+
let url = typeof update === "string" ? update : typeof update === "object" ? update.url : undefined;
|
|
3835
|
+
if (!url) {
|
|
3836
|
+
let manifest = null;
|
|
3837
|
+
try {
|
|
3838
|
+
manifest = readManifest(root);
|
|
3839
|
+
} catch {}
|
|
3840
|
+
if (!manifest) {
|
|
3841
|
+
warn(`app.json "update": true needs a server-backed project — use "update": {"url": "https://…/app.js"} for custom hosting. OTA left unconfigured.`);
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
url = `${requireApiUrl(loadConfig())}/code/${manifest.uuid}.js`;
|
|
3845
|
+
}
|
|
3846
|
+
if (!url.startsWith("https://"))
|
|
3847
|
+
warn(`update URL is not https — iOS blocks it on device (ATS): ${url}`);
|
|
3848
|
+
return url;
|
|
3849
|
+
};
|
|
3812
3850
|
var appSync = async (_args) => {
|
|
3813
3851
|
const root = projectRoot();
|
|
3814
3852
|
const config = readJson(join11(root, "app.json"));
|
|
@@ -3916,7 +3954,10 @@ var appSync = async (_args) => {
|
|
|
3916
3954
|
const mergedKeys = {};
|
|
3917
3955
|
for (const [key, values] of Object.entries(infoValues))
|
|
3918
3956
|
mergedKeys[key] = [...new Set(values)].join(" ");
|
|
3919
|
-
|
|
3957
|
+
const updateUrl = resolveUpdateUrl(config.update, root);
|
|
3958
|
+
writeFileSync7(join11(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys, updateUrl));
|
|
3959
|
+
if (updateUrl)
|
|
3960
|
+
log(`${c.green("update")} OTA source ${updateUrl} — checked natively at launch, new code applies next launch`);
|
|
3920
3961
|
if (pluginSpecs.length > 0 || existsSync8(lockPath))
|
|
3921
3962
|
writeJson(lockPath, { plugins: newLock });
|
|
3922
3963
|
const pbxPath = join11(iosDir, "App.xcodeproj", "project.pbxproj");
|
|
@@ -4046,6 +4087,10 @@ var appDoctor = async (_args) => {
|
|
|
4046
4087
|
const wanted = Object.keys(config.plugins ?? {});
|
|
4047
4088
|
const inSync = wanted.every((id) => lock[id]) && Object.keys(lock).every((id) => wanted.includes(id));
|
|
4048
4089
|
check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`");
|
|
4090
|
+
if (config.update) {
|
|
4091
|
+
const plist = readFileSync10(join11(root, "ios", "App", "Info.plist"), "utf8");
|
|
4092
|
+
check(plist.includes("<key>LeCodesUpdateURL</key>"), "OTA update URL in Info.plist", "run `lecodes app sync`", false);
|
|
4093
|
+
}
|
|
4049
4094
|
const asc = readJson(join11(homedir2(), ".lecodes", "asc.json"));
|
|
4050
4095
|
check(!!asc?.keyPath, "App Store Connect API key configured", "pass --key-path/--key-id/--issuer-id to `lecodes app upload` once to store it", false);
|
|
4051
4096
|
log("");
|
|
@@ -4821,15 +4866,8 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
|
|
|
4821
4866
|
}
|
|
4822
4867
|
};
|
|
4823
4868
|
|
|
4824
|
-
// src/
|
|
4825
|
-
var
|
|
4826
|
-
"screens": {
|
|
4827
|
-
"home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
|
|
4828
|
-
},
|
|
4829
|
-
"edges": []
|
|
4830
|
-
}
|
|
4831
|
-
`;
|
|
4832
|
-
var SPEC_MD = `# Concept
|
|
4869
|
+
// ../lecodes-design/src/core/concept.ts
|
|
4870
|
+
var SPEC_TEMPLATE = `# Concept
|
|
4833
4871
|
|
|
4834
4872
|
The narrative half of this design. \`meta.json\` + the canvas hold the *visual/flow* half (screens,
|
|
4835
4873
|
positions, edges); this file holds what never renders as a screen — the data model, the actors, and
|
|
@@ -4863,6 +4901,16 @@ which is the screen's *kind* — list, detail, form…)
|
|
|
4863
4901
|
Behaviour that isn't visible in a single screen — validation, permissions, notifications, edge
|
|
4864
4902
|
cases — and the decisions you've made and why, so they aren't re-litigated later.
|
|
4865
4903
|
`;
|
|
4904
|
+
|
|
4905
|
+
// ../lecodes-design/src/templates.ts
|
|
4906
|
+
var SPEC_MD = SPEC_TEMPLATE;
|
|
4907
|
+
var META_JSON = `{
|
|
4908
|
+
"screens": {
|
|
4909
|
+
"home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
|
|
4910
|
+
},
|
|
4911
|
+
"edges": []
|
|
4912
|
+
}
|
|
4913
|
+
`;
|
|
4866
4914
|
var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
|
|
4867
4915
|
// App font: one theme() call here sets the default text font everywhere. font("id") installs a
|
|
4868
4916
|
// registry family (vendored at compile time) and returns its name — no loading code needed.
|
|
@@ -5025,6 +5073,26 @@ positions or descriptions.
|
|
|
5025
5073
|
\`lecodes design check\` lists screens still untagged (never blocking). *(A screen's **states** are
|
|
5026
5074
|
a separate thing — declared in the source, not meta; see "Screen states" below.)*
|
|
5027
5075
|
|
|
5076
|
+
## The design is testable — names carry into the app
|
|
5077
|
+
|
|
5078
|
+
Every edge with an \`activator\` doubles as a **flow test** for the real app: \`lecodes test\` (run
|
|
5079
|
+
at the project root once the app exists) walks the graph from the start screen, taps each edge's
|
|
5080
|
+
activator, and expects to land on a screen named like the edge's target. The design stays a living
|
|
5081
|
+
contract instead of a discarded sketch — but only if the app build keeps the wiring:
|
|
5082
|
+
|
|
5083
|
+
- **Keep the design's \`name\`s** when translating a screen to app code — every element an edge's
|
|
5084
|
+
\`activator\` references keeps its \`name\` (\`.style({ name: "submit" })\`).
|
|
5085
|
+
- **Name each app screen's root with its design screen id** — \`UIScreen([ … ]).style({ name:
|
|
5086
|
+
"login" })\` — that's how the runner knows where a tap landed.
|
|
5087
|
+
- Edges with no activator (backend/system-driven) and \`@state\` edges are reported as *not
|
|
5088
|
+
machine-verifiable* — cover the important ones with hand-written \`tests/*.flow.json\` scenarios
|
|
5089
|
+
(same step schema as \`lecodes render --script\`; tab-bar navigation needs these too, since the
|
|
5090
|
+
bar draws no edges).
|
|
5091
|
+
|
|
5092
|
+
Names also appear in every headless render (JSON snapshots, the board's live elements), so they
|
|
5093
|
+
double as stable selectors for any scripted interaction — naming interactive elements is never
|
|
5094
|
+
wasted work.
|
|
5095
|
+
|
|
5028
5096
|
## The concept — spec.md
|
|
5029
5097
|
|
|
5030
5098
|
\`meta.json\` + the canvas are the *visual/flow* half of the design; \`spec.md\` is the *narrative*
|
|
@@ -5246,7 +5314,6 @@ var README_HINT = `# design/
|
|
|
5246
5314
|
LeCodes Design canvas — run \`lecodes design\` here and open the printed URL.
|
|
5247
5315
|
See CLAUDE.md for the conventions (it's written for AI assistants, but it's the spec).
|
|
5248
5316
|
`;
|
|
5249
|
-
|
|
5250
5317
|
// src/commands/design.ts
|
|
5251
5318
|
var DEFAULT_DIR = "design";
|
|
5252
5319
|
var DEFAULT_PORT = 4477;
|
|
@@ -6519,50 +6586,47 @@ var login = async (args) => {
|
|
|
6519
6586
|
};
|
|
6520
6587
|
|
|
6521
6588
|
// src/commands/render.ts
|
|
6522
|
-
import { writeFileSync as writeFileSync14 } from "node:fs";
|
|
6523
|
-
import { resolve as resolve6 } from "node:path";
|
|
6589
|
+
import { mkdirSync as mkdirSync13, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "node:fs";
|
|
6590
|
+
import { dirname as dirname11, resolve as resolve6 } from "node:path";
|
|
6591
|
+
|
|
6592
|
+
// src/compile/headlessBundle.ts
|
|
6524
6593
|
var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
|
|
6525
|
-
var
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
if (n.length === 4 && n.every((v) => Number.isFinite(v)))
|
|
6535
|
-
return { safeArea: n, safeAreaBars: [] };
|
|
6536
|
-
throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
|
|
6537
|
-
}
|
|
6538
|
-
const device = flagStr(args, "device");
|
|
6539
|
-
if (device) {
|
|
6540
|
-
const preset = DEVICE_SAFE_AREA[device];
|
|
6541
|
-
if (!preset)
|
|
6542
|
-
throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
|
|
6543
|
-
return { safeArea: preset.insets, safeAreaBars: preset.bars };
|
|
6594
|
+
var compileHeadlessBundle = async (args) => {
|
|
6595
|
+
let root;
|
|
6596
|
+
let manifest;
|
|
6597
|
+
try {
|
|
6598
|
+
root = findProjectRoot(process.cwd());
|
|
6599
|
+
manifest = readManifest(root);
|
|
6600
|
+
} catch {
|
|
6601
|
+
root = process.cwd();
|
|
6602
|
+
manifest = null;
|
|
6544
6603
|
}
|
|
6545
|
-
return;
|
|
6546
|
-
};
|
|
6547
|
-
var render = async (args) => {
|
|
6548
|
-
const timeoutMs = Number(flagStr(args, "timeout")) || 15000;
|
|
6549
|
-
const killer = setTimeout(() => {
|
|
6550
|
-
warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
|
|
6551
|
-
process.exit(1);
|
|
6552
|
-
}, timeoutMs);
|
|
6553
|
-
killer.unref?.();
|
|
6554
|
-
const root = findProjectRoot(process.cwd());
|
|
6555
|
-
const manifest = readManifest(root);
|
|
6556
|
-
const config = loadConfig();
|
|
6557
|
-
const apiUrl = requireApiUrl(config);
|
|
6558
|
-
const token = requireToken(config);
|
|
6559
|
-
note("Resolving project assets…");
|
|
6560
|
-
const project = await getProject(apiUrl, token, manifest.uuid);
|
|
6561
|
-
const { entries, warnings: collectWarnings } = collectEntries(root, manifest, project);
|
|
6562
6604
|
const publicUrlFlag = flagStr(args, "public-url");
|
|
6563
|
-
const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
|
|
6605
|
+
const useLocalAssets = manifest ? !publicUrlFlag && !flagBool(args, "remote-assets") : true;
|
|
6606
|
+
let entries;
|
|
6607
|
+
let warnings;
|
|
6608
|
+
let name;
|
|
6609
|
+
let publicUrl;
|
|
6610
|
+
if (manifest) {
|
|
6611
|
+
const config = loadConfig();
|
|
6612
|
+
const apiUrl = requireApiUrl(config);
|
|
6613
|
+
const token = requireToken(config);
|
|
6614
|
+
note("Resolving project assets…");
|
|
6615
|
+
const project = await getProject(apiUrl, token, manifest.uuid);
|
|
6616
|
+
({ entries, warnings } = collectEntries(root, manifest, project));
|
|
6617
|
+
name = manifest.name;
|
|
6618
|
+
publicUrl = useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl;
|
|
6619
|
+
if (useLocalAssets)
|
|
6620
|
+
warnings = warnings.filter((w) => !w.includes("No server URL"));
|
|
6621
|
+
} else {
|
|
6622
|
+
({ entries, warnings } = collectLocalEntries(root, null, ""));
|
|
6623
|
+
name = root.split(/[\\/]/).pop() ?? "project";
|
|
6624
|
+
publicUrl = LOCAL_ASSET_SCHEME2;
|
|
6625
|
+
if (flagBool(args, "remote-assets") || publicUrlFlag) {
|
|
6626
|
+
warnings.push("This is a local project (no server) — assets always load from the working tree.");
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6564
6629
|
const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
|
|
6565
|
-
const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
|
|
6566
6630
|
await applyProjectFonts(compileEntries, useLocalAssets ? {
|
|
6567
6631
|
mode: "vendor",
|
|
6568
6632
|
root,
|
|
@@ -6573,8 +6637,8 @@ var render = async (args) => {
|
|
|
6573
6637
|
note("Compiling…");
|
|
6574
6638
|
const js = await compileProject2({
|
|
6575
6639
|
entries: compileEntries,
|
|
6576
|
-
name
|
|
6577
|
-
publicUrl
|
|
6640
|
+
name,
|
|
6641
|
+
publicUrl,
|
|
6578
6642
|
entryOverride: flagStr(args, "entry"),
|
|
6579
6643
|
header: false,
|
|
6580
6644
|
format: "iife"
|
|
@@ -6585,15 +6649,75 @@ var render = async (args) => {
|
|
|
6585
6649
|
if (js.includes("_creator2d") && !useLocalAssets && !flagBool(args, "wait-network")) {
|
|
6586
6650
|
warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).");
|
|
6587
6651
|
}
|
|
6588
|
-
|
|
6652
|
+
return { js, root, warnings, useLocalAssets };
|
|
6653
|
+
};
|
|
6654
|
+
|
|
6655
|
+
// src/commands/render.ts
|
|
6656
|
+
var DEVICE_SAFE_AREA = {
|
|
6657
|
+
none: { insets: [0, 0, 0, 0], bars: [] },
|
|
6658
|
+
iphone: { insets: [59, 0, 34, 0], bars: [] },
|
|
6659
|
+
android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
|
|
6660
|
+
};
|
|
6661
|
+
var resolveSafeAreaChoice = (explicit, device) => {
|
|
6662
|
+
if (explicit) {
|
|
6663
|
+
const n = explicit.split(",").map((s) => Number(s.trim()));
|
|
6664
|
+
if (n.length === 4 && n.every((v) => Number.isFinite(v)))
|
|
6665
|
+
return { safeArea: n, safeAreaBars: [] };
|
|
6666
|
+
throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
|
|
6667
|
+
}
|
|
6668
|
+
if (device) {
|
|
6669
|
+
const preset = DEVICE_SAFE_AREA[device];
|
|
6670
|
+
if (!preset)
|
|
6671
|
+
throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
|
|
6672
|
+
return { safeArea: preset.insets, safeAreaBars: preset.bars };
|
|
6673
|
+
}
|
|
6674
|
+
return;
|
|
6675
|
+
};
|
|
6676
|
+
var requireRenderer = async () => {
|
|
6589
6677
|
try {
|
|
6590
|
-
|
|
6678
|
+
return await import("lecodes-renderer/headless");
|
|
6591
6679
|
} catch {
|
|
6592
6680
|
throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
|
|
6593
6681
|
}
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6682
|
+
};
|
|
6683
|
+
var fileScenarioIO = (dir) => ({
|
|
6684
|
+
write: (relPath, data) => {
|
|
6685
|
+
const abs = resolve6(dir, relPath);
|
|
6686
|
+
mkdirSync13(dirname11(abs), { recursive: true });
|
|
6687
|
+
writeFileSync14(abs, data);
|
|
6688
|
+
}
|
|
6689
|
+
});
|
|
6690
|
+
var readScenarioFile = (path) => {
|
|
6691
|
+
let raw;
|
|
6692
|
+
try {
|
|
6693
|
+
raw = JSON.parse(readFileSync17(resolve6(path), "utf8"));
|
|
6694
|
+
} catch (e) {
|
|
6695
|
+
throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`);
|
|
6696
|
+
}
|
|
6697
|
+
if (Array.isArray(raw))
|
|
6698
|
+
return { steps: raw };
|
|
6699
|
+
if (typeof raw !== "object" || raw === null)
|
|
6700
|
+
throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`);
|
|
6701
|
+
const file = raw;
|
|
6702
|
+
if (file.steps === undefined)
|
|
6703
|
+
throw new CliError(`Scenario "${path}" has no "steps".`);
|
|
6704
|
+
return file;
|
|
6705
|
+
};
|
|
6706
|
+
var render = async (args) => {
|
|
6707
|
+
const scriptFlag = flagStr(args, "script");
|
|
6708
|
+
const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null;
|
|
6709
|
+
const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0;
|
|
6710
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || 15000 + stepCount * 3000;
|
|
6711
|
+
const killer = setTimeout(() => {
|
|
6712
|
+
warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
|
|
6713
|
+
process.exit(1);
|
|
6714
|
+
}, timeoutMs);
|
|
6715
|
+
killer.unref?.();
|
|
6716
|
+
const { js, warnings, useLocalAssets } = await compileHeadlessBundle(args);
|
|
6717
|
+
const renderer = await requireRenderer();
|
|
6718
|
+
const width = Number(flagStr(args, "width")) || scenario?.width || 390;
|
|
6719
|
+
const height = Number(flagStr(args, "height")) || scenario?.height || 844;
|
|
6720
|
+
const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
|
|
6597
6721
|
const timeStr = flagStr(args, "time");
|
|
6598
6722
|
const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
|
|
6599
6723
|
const timing = {
|
|
@@ -6602,13 +6726,36 @@ var render = async (args) => {
|
|
|
6602
6726
|
localAssets: useLocalAssets,
|
|
6603
6727
|
timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
|
|
6604
6728
|
afterAnimations: !flagBool(args, "no-after-animations"),
|
|
6605
|
-
...
|
|
6729
|
+
...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
|
|
6606
6730
|
onConsole
|
|
6607
6731
|
};
|
|
6608
|
-
const done = (flush) => flush(() => {
|
|
6732
|
+
const done = (flush, code = 0) => flush(() => {
|
|
6609
6733
|
clearTimeout(killer);
|
|
6610
|
-
process.exit(
|
|
6734
|
+
process.exit(code);
|
|
6611
6735
|
});
|
|
6736
|
+
if (scenario) {
|
|
6737
|
+
const steps = renderer.parseScenarioSteps(scenario.steps);
|
|
6738
|
+
const outDir = flagStr(args, "out-dir") ?? ".";
|
|
6739
|
+
const needsRaster = steps.some((s) => s.screenshot !== undefined);
|
|
6740
|
+
note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`);
|
|
6741
|
+
const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" });
|
|
6742
|
+
try {
|
|
6743
|
+
const result2 = await renderer.runScenario(session, steps, fileScenarioIO(outDir));
|
|
6744
|
+
for (const w of [...warnings, ...session.warnings])
|
|
6745
|
+
warnErr(w);
|
|
6746
|
+
for (const st of result2.steps) {
|
|
6747
|
+
logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}
|
|
6748
|
+
${c.red(st.error ?? "failed")}`);
|
|
6749
|
+
}
|
|
6750
|
+
if (!result2.ok && result2.failureArtifacts.length > 0) {
|
|
6751
|
+
logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve6(outDir, p)).join(", ")}`));
|
|
6752
|
+
}
|
|
6753
|
+
logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
|
|
6754
|
+
return done((cb) => cb(), result2.ok ? 0 : 1);
|
|
6755
|
+
} finally {
|
|
6756
|
+
session.dispose();
|
|
6757
|
+
}
|
|
6758
|
+
}
|
|
6612
6759
|
const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
|
|
6613
6760
|
if (pngFlag !== undefined) {
|
|
6614
6761
|
note("Rendering PNG…");
|
|
@@ -6649,8 +6796,8 @@ var render = async (args) => {
|
|
|
6649
6796
|
};
|
|
6650
6797
|
|
|
6651
6798
|
// src/commands/pull.ts
|
|
6652
|
-
import { existsSync as existsSync17, mkdirSync as
|
|
6653
|
-
import { dirname as
|
|
6799
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync14, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
|
|
6800
|
+
import { dirname as dirname12, join as join19 } from "node:path";
|
|
6654
6801
|
var pull = async (args) => {
|
|
6655
6802
|
const root = findProjectRoot(process.cwd());
|
|
6656
6803
|
const manifest = readManifest(root);
|
|
@@ -6710,7 +6857,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
6710
6857
|
for (const asset of targets) {
|
|
6711
6858
|
const key = pathById.get(asset.id);
|
|
6712
6859
|
const abs = join19(root, ...key.replace(/^\//, "").split("/"));
|
|
6713
|
-
|
|
6860
|
+
mkdirSync14(dirname12(abs), { recursive: true });
|
|
6714
6861
|
let sha;
|
|
6715
6862
|
if (asset.type === "file") {
|
|
6716
6863
|
if (!asset.file?.src)
|
|
@@ -6733,7 +6880,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
|
|
|
6733
6880
|
|
|
6734
6881
|
// src/commands/push.ts
|
|
6735
6882
|
import { basename as basename10 } from "node:path";
|
|
6736
|
-
import { readFileSync as
|
|
6883
|
+
import { readFileSync as readFileSync18 } from "node:fs";
|
|
6737
6884
|
var push = async (args) => {
|
|
6738
6885
|
const root = findProjectRoot(process.cwd());
|
|
6739
6886
|
const manifest = readManifest(root);
|
|
@@ -6792,9 +6939,9 @@ var push = async (args) => {
|
|
|
6792
6939
|
if (source)
|
|
6793
6940
|
entry.assetId = source.id;
|
|
6794
6941
|
if (isTextKind(kind)) {
|
|
6795
|
-
entry.text =
|
|
6942
|
+
entry.text = readFileSync18(file.absPath, "utf8");
|
|
6796
6943
|
} else if (!source || source.sha !== file.sha) {
|
|
6797
|
-
const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path),
|
|
6944
|
+
const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync18(file.absPath));
|
|
6798
6945
|
entry.blobFileId = blob.id;
|
|
6799
6946
|
uploaded++;
|
|
6800
6947
|
}
|
|
@@ -6847,11 +6994,11 @@ var reportPublish = (res) => {
|
|
|
6847
6994
|
};
|
|
6848
6995
|
|
|
6849
6996
|
// src/commands/scene.ts
|
|
6850
|
-
import { existsSync as existsSync19, readFileSync as
|
|
6851
|
-
import { basename as basename11, dirname as
|
|
6997
|
+
import { existsSync as existsSync19, readFileSync as readFileSync20, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
|
|
6998
|
+
import { basename as basename11, dirname as dirname13, join as join21, resolve as resolve7 } from "node:path";
|
|
6852
6999
|
|
|
6853
7000
|
// src/compile/sceneCompile.ts
|
|
6854
|
-
import { existsSync as existsSync18, readFileSync as
|
|
7001
|
+
import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
|
|
6855
7002
|
import { join as join20 } from "node:path";
|
|
6856
7003
|
var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
|
|
6857
7004
|
var rel = (key) => key.replace(/^\//, "");
|
|
@@ -6862,7 +7009,7 @@ var resolveHarness = (loadSceneHarness2) => {
|
|
|
6862
7009
|
} catch {
|
|
6863
7010
|
const vendored = join20(distRoot, "runtime", "scene-harness.json");
|
|
6864
7011
|
if (existsSync18(vendored))
|
|
6865
|
-
return JSON.parse(
|
|
7012
|
+
return JSON.parse(readFileSync19(vendored, "utf8"));
|
|
6866
7013
|
throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
|
|
6867
7014
|
}
|
|
6868
7015
|
};
|
|
@@ -6874,7 +7021,7 @@ var compileSceneEditor = async (opts) => {
|
|
|
6874
7021
|
for (const file of scan.files) {
|
|
6875
7022
|
const path = "./" + rel(file.path);
|
|
6876
7023
|
if (TEXT_EXT.test(path)) {
|
|
6877
|
-
entries.push({ path, type: "text", text:
|
|
7024
|
+
entries.push({ path, type: "text", text: readFileSync19(file.absPath, "utf8") });
|
|
6878
7025
|
} else {
|
|
6879
7026
|
entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
|
|
6880
7027
|
}
|
|
@@ -6918,7 +7065,7 @@ var getContext2 = (args) => {
|
|
|
6918
7065
|
if (existsSync19(abs) && statSync4(abs).isDirectory())
|
|
6919
7066
|
root = abs;
|
|
6920
7067
|
else if (abs.endsWith(".scene.ts") && existsSync19(abs))
|
|
6921
|
-
root =
|
|
7068
|
+
root = dirname13(abs);
|
|
6922
7069
|
else
|
|
6923
7070
|
throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
|
|
6924
7071
|
}
|
|
@@ -6931,7 +7078,7 @@ var writeMcpConfig2 = (root, port) => {
|
|
|
6931
7078
|
let config = {};
|
|
6932
7079
|
if (existsSync19(path)) {
|
|
6933
7080
|
try {
|
|
6934
|
-
config = JSON.parse(
|
|
7081
|
+
config = JSON.parse(readFileSync20(path, "utf8"));
|
|
6935
7082
|
} catch {
|
|
6936
7083
|
warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
|
|
6937
7084
|
return;
|
|
@@ -7023,23 +7170,239 @@ var status = async (_args) => {
|
|
|
7023
7170
|
info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`));
|
|
7024
7171
|
};
|
|
7025
7172
|
|
|
7173
|
+
// src/commands/test.ts
|
|
7174
|
+
import { existsSync as existsSync20, readFileSync as readFileSync21, readdirSync as readdirSync9 } from "node:fs";
|
|
7175
|
+
import { resolve as resolve8 } from "node:path";
|
|
7176
|
+
var deriveDesignFlows = (meta, entryScreen) => {
|
|
7177
|
+
const edges = meta.edges ?? [];
|
|
7178
|
+
const isState = (id) => id.includes("@");
|
|
7179
|
+
const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to));
|
|
7180
|
+
const prev = new Map;
|
|
7181
|
+
const visited = new Set([entryScreen]);
|
|
7182
|
+
const queue = [entryScreen];
|
|
7183
|
+
while (queue.length > 0) {
|
|
7184
|
+
const at = queue.shift();
|
|
7185
|
+
for (const e of verifiable) {
|
|
7186
|
+
if (e.from !== at || visited.has(e.to))
|
|
7187
|
+
continue;
|
|
7188
|
+
visited.add(e.to);
|
|
7189
|
+
prev.set(e.to, e);
|
|
7190
|
+
queue.push(e.to);
|
|
7191
|
+
}
|
|
7192
|
+
}
|
|
7193
|
+
const pathTo = (screen) => {
|
|
7194
|
+
if (screen === entryScreen)
|
|
7195
|
+
return [];
|
|
7196
|
+
if (!visited.has(screen))
|
|
7197
|
+
return null;
|
|
7198
|
+
const hops = [];
|
|
7199
|
+
for (let at = screen;at !== entryScreen; at = hops[0].from)
|
|
7200
|
+
hops.unshift(prev.get(at));
|
|
7201
|
+
return hops;
|
|
7202
|
+
};
|
|
7203
|
+
const flows = [];
|
|
7204
|
+
const skipped = [];
|
|
7205
|
+
for (const e of edges) {
|
|
7206
|
+
const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`;
|
|
7207
|
+
if (!e.activator) {
|
|
7208
|
+
skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" });
|
|
7209
|
+
continue;
|
|
7210
|
+
}
|
|
7211
|
+
if (isState(e.from) || isState(e.to)) {
|
|
7212
|
+
skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" });
|
|
7213
|
+
continue;
|
|
7214
|
+
}
|
|
7215
|
+
const path = pathTo(e.from);
|
|
7216
|
+
if (path === null) {
|
|
7217
|
+
skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` });
|
|
7218
|
+
continue;
|
|
7219
|
+
}
|
|
7220
|
+
flows.push({
|
|
7221
|
+
name,
|
|
7222
|
+
kind: "flow",
|
|
7223
|
+
steps: [
|
|
7224
|
+
...path.map((hop) => ({ tap: hop.activator, expect: { screen: hop.to } })),
|
|
7225
|
+
{ tap: e.activator, expect: { screen: e.to } }
|
|
7226
|
+
]
|
|
7227
|
+
});
|
|
7228
|
+
}
|
|
7229
|
+
return { flows, skipped };
|
|
7230
|
+
};
|
|
7231
|
+
var slug3 = (s) => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case";
|
|
7232
|
+
var readFlowFile = (abs, displayName) => {
|
|
7233
|
+
let raw;
|
|
7234
|
+
try {
|
|
7235
|
+
raw = JSON.parse(readFileSync21(abs, "utf8"));
|
|
7236
|
+
} catch (e) {
|
|
7237
|
+
throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`);
|
|
7238
|
+
}
|
|
7239
|
+
if (Array.isArray(raw))
|
|
7240
|
+
return { name: displayName, kind: "file", steps: raw };
|
|
7241
|
+
if (typeof raw !== "object" || raw === null || raw.steps === undefined) {
|
|
7242
|
+
throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`);
|
|
7243
|
+
}
|
|
7244
|
+
const file = raw;
|
|
7245
|
+
return { name: file.name ?? displayName, kind: "file", steps: file.steps, width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea };
|
|
7246
|
+
};
|
|
7247
|
+
var test = async (args) => {
|
|
7248
|
+
const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
|
|
7249
|
+
const renderer = await requireRenderer();
|
|
7250
|
+
const testsDir = resolve8(root, flagStr(args, "dir") ?? "tests");
|
|
7251
|
+
const designMetaPath = resolve8(root, flagStr(args, "design-dir") ?? "design", "meta.json");
|
|
7252
|
+
const asJson = flagBool(args, "json");
|
|
7253
|
+
const out = asJson ? logErr : log;
|
|
7254
|
+
const cases = [];
|
|
7255
|
+
if (args._.length > 0) {
|
|
7256
|
+
for (const given of args._) {
|
|
7257
|
+
const candidates = [resolve8(given), resolve8(testsDir, given), resolve8(testsDir, `${given}.flow.json`)];
|
|
7258
|
+
const abs = candidates.find((p) => existsSync20(p));
|
|
7259
|
+
if (!abs)
|
|
7260
|
+
throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
|
|
7261
|
+
cases.push(readFlowFile(abs, given));
|
|
7262
|
+
}
|
|
7263
|
+
} else if (existsSync20(testsDir)) {
|
|
7264
|
+
for (const f2 of readdirSync9(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
|
|
7265
|
+
cases.push(readFlowFile(resolve8(testsDir, f2), f2));
|
|
7266
|
+
}
|
|
7267
|
+
}
|
|
7268
|
+
const flowsOnly = flagBool(args, "flows");
|
|
7269
|
+
const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
|
|
7270
|
+
if (flowsOnly)
|
|
7271
|
+
cases.length = 0;
|
|
7272
|
+
let skipped = [];
|
|
7273
|
+
let entryScreen = null;
|
|
7274
|
+
if ((wantFlows || flowsOnly) && existsSync20(designMetaPath)) {
|
|
7275
|
+
let meta;
|
|
7276
|
+
try {
|
|
7277
|
+
meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
|
|
7278
|
+
} catch (e) {
|
|
7279
|
+
throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
7280
|
+
}
|
|
7281
|
+
if ((meta.edges ?? []).length > 0) {
|
|
7282
|
+
note("Probing the start screen…");
|
|
7283
|
+
const probe = await renderer.createSession(js, { raster: false, localAssets: useLocalAssets });
|
|
7284
|
+
entryScreen = probe.screenName();
|
|
7285
|
+
probe.dispose();
|
|
7286
|
+
if (entryScreen === null) {
|
|
7287
|
+
warnErr('Design flows skipped: the app\'s start screen root has no `name`. Name each screen root with its design screen id (e.g. UIScreen([...], { name: "login" })).');
|
|
7288
|
+
} else {
|
|
7289
|
+
const derived = deriveDesignFlows(meta, entryScreen);
|
|
7290
|
+
cases.push(...derived.flows);
|
|
7291
|
+
skipped = derived.skipped;
|
|
7292
|
+
}
|
|
7293
|
+
}
|
|
7294
|
+
} else if (flowsOnly) {
|
|
7295
|
+
throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`);
|
|
7296
|
+
}
|
|
7297
|
+
if (cases.length === 0) {
|
|
7298
|
+
for (const w of warnings)
|
|
7299
|
+
warnErr(w);
|
|
7300
|
+
throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`);
|
|
7301
|
+
}
|
|
7302
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || 20000 + cases.length * 1e4;
|
|
7303
|
+
const killer = setTimeout(() => {
|
|
7304
|
+
warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`);
|
|
7305
|
+
process.exit(1);
|
|
7306
|
+
}, timeoutMs);
|
|
7307
|
+
killer.unref?.();
|
|
7308
|
+
const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
|
|
7309
|
+
const artifactsRoot = resolve8(root, flagStr(args, "artifacts") ?? resolve8(testsDir, ".artifacts"));
|
|
7310
|
+
const results = [];
|
|
7311
|
+
for (const kase of cases) {
|
|
7312
|
+
const flagSettle = flagStr(args, "settle");
|
|
7313
|
+
let result;
|
|
7314
|
+
try {
|
|
7315
|
+
const steps = renderer.parseScenarioSteps(kase.steps);
|
|
7316
|
+
const session = await renderer.createSession(js, {
|
|
7317
|
+
width: Number(flagStr(args, "width")) || kase.width || 390,
|
|
7318
|
+
height: Number(flagStr(args, "height")) || kase.height || 844,
|
|
7319
|
+
settleMs: flagSettle !== undefined ? Number(flagSettle) : kase.settle,
|
|
7320
|
+
waitNetwork: flagBool(args, "wait-network"),
|
|
7321
|
+
localAssets: useLocalAssets,
|
|
7322
|
+
...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? kase.safeArea, flagStr(args, "device") ?? kase.device),
|
|
7323
|
+
onConsole
|
|
7324
|
+
});
|
|
7325
|
+
try {
|
|
7326
|
+
const io = fileScenarioIO(resolve8(artifactsRoot, slug3(kase.name)));
|
|
7327
|
+
const outcome = await renderer.runScenario(session, steps, io);
|
|
7328
|
+
result = {
|
|
7329
|
+
name: kase.name,
|
|
7330
|
+
kind: kase.kind,
|
|
7331
|
+
ok: outcome.ok,
|
|
7332
|
+
steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
|
|
7333
|
+
artifacts: outcome.failureArtifacts.map((p) => resolve8(artifactsRoot, slug3(kase.name), p))
|
|
7334
|
+
};
|
|
7335
|
+
} finally {
|
|
7336
|
+
session.dispose();
|
|
7337
|
+
}
|
|
7338
|
+
} catch (e) {
|
|
7339
|
+
result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] };
|
|
7340
|
+
}
|
|
7341
|
+
results.push(result);
|
|
7342
|
+
if (result.ok) {
|
|
7343
|
+
out(`${c.green("✓")} ${result.name}`);
|
|
7344
|
+
} else {
|
|
7345
|
+
out(`${c.red("✗")} ${result.name}`);
|
|
7346
|
+
if (result.error)
|
|
7347
|
+
out(` ${c.red(result.error)}`);
|
|
7348
|
+
for (const s of result.steps) {
|
|
7349
|
+
out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`);
|
|
7350
|
+
}
|
|
7351
|
+
if (result.artifacts.length > 0)
|
|
7352
|
+
out(c.dim(` failing frame: ${result.artifacts.join(", ")}`));
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
for (const w of warnings)
|
|
7356
|
+
warnErr(w);
|
|
7357
|
+
const passed = results.filter((r) => r.ok).length;
|
|
7358
|
+
const failed = results.length - passed;
|
|
7359
|
+
if (skipped.length > 0) {
|
|
7360
|
+
out("");
|
|
7361
|
+
out(c.dim(`Not machine-verifiable (${skipped.length}):`));
|
|
7362
|
+
for (const s of skipped)
|
|
7363
|
+
out(c.dim(` - ${s.name}: ${s.reason}`));
|
|
7364
|
+
}
|
|
7365
|
+
out("");
|
|
7366
|
+
const flowCount = results.filter((r) => r.kind === "flow").length;
|
|
7367
|
+
const summary = [
|
|
7368
|
+
`${passed} passed`,
|
|
7369
|
+
failed > 0 ? c.red(`${failed} failed`) : undefined,
|
|
7370
|
+
skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
|
|
7371
|
+
flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined
|
|
7372
|
+
].filter(Boolean).join(", ");
|
|
7373
|
+
out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`);
|
|
7374
|
+
if (asJson) {
|
|
7375
|
+
process.stdout.write(JSON.stringify({
|
|
7376
|
+
ok: failed === 0,
|
|
7377
|
+
passed,
|
|
7378
|
+
failed,
|
|
7379
|
+
entryScreen,
|
|
7380
|
+
cases: results,
|
|
7381
|
+
unverifiable: skipped
|
|
7382
|
+
}, null, 2) + `
|
|
7383
|
+
`);
|
|
7384
|
+
}
|
|
7385
|
+
clearTimeout(killer);
|
|
7386
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
7387
|
+
};
|
|
7388
|
+
|
|
7026
7389
|
// src/index.ts
|
|
7027
|
-
import { existsSync as
|
|
7390
|
+
import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
|
|
7028
7391
|
import { createRequire as createRequire4 } from "node:module";
|
|
7029
|
-
import { delimiter as delimiter2, dirname as
|
|
7392
|
+
import { delimiter as delimiter2, dirname as dirname15, join as join23 } from "node:path";
|
|
7030
7393
|
|
|
7031
7394
|
// src/commands/install.ts
|
|
7032
7395
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
7033
|
-
import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as
|
|
7396
|
+
import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync21, mkdirSync as mkdirSync15, readFileSync as readFileSync22, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
|
|
7034
7397
|
import { homedir as homedir3 } from "node:os";
|
|
7035
|
-
import { basename as basename12, delimiter, dirname as
|
|
7398
|
+
import { basename as basename12, delimiter, dirname as dirname14, join as join22, resolve as resolve9, sep } from "node:path";
|
|
7036
7399
|
var isWin = process.platform === "win32";
|
|
7037
7400
|
var HOME = homedir3();
|
|
7038
7401
|
var safeRealpath = (p) => {
|
|
7039
7402
|
try {
|
|
7040
7403
|
return realpathSync(p);
|
|
7041
7404
|
} catch {
|
|
7042
|
-
return
|
|
7405
|
+
return resolve9(p);
|
|
7043
7406
|
}
|
|
7044
7407
|
};
|
|
7045
7408
|
var onPath = (dir) => {
|
|
@@ -7055,19 +7418,19 @@ var onPath = (dir) => {
|
|
|
7055
7418
|
});
|
|
7056
7419
|
};
|
|
7057
7420
|
var install = async (args) => {
|
|
7058
|
-
const bundle =
|
|
7421
|
+
const bundle = dirname14(process.execPath);
|
|
7059
7422
|
const exeName = basename12(process.execPath);
|
|
7060
7423
|
const chiselName = isWin ? "chisel.exe" : "chisel";
|
|
7061
|
-
if (!
|
|
7424
|
+
if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
|
|
7062
7425
|
throw new CliError("`lecodes install` only works from the packaged release build (the archive containing lecodes + " + "chisel + runtime/). You appear to be running from source or an npm install — install globally " + "with your package manager there instead.");
|
|
7063
7426
|
}
|
|
7064
|
-
const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) =>
|
|
7427
|
+
const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync21(join22(bundle, f2)));
|
|
7065
7428
|
if (isWin)
|
|
7066
7429
|
return installWindows(args, bundle, companions);
|
|
7067
7430
|
return installUnix(args, bundle, companions);
|
|
7068
7431
|
};
|
|
7069
7432
|
var copyBundle = (bundle, target, companions) => {
|
|
7070
|
-
|
|
7433
|
+
mkdirSync15(target, { recursive: true });
|
|
7071
7434
|
if (safeRealpath(target) === safeRealpath(bundle))
|
|
7072
7435
|
return;
|
|
7073
7436
|
for (const f2 of companions) {
|
|
@@ -7085,8 +7448,8 @@ var permError = (e, dir, system) => {
|
|
|
7085
7448
|
};
|
|
7086
7449
|
var installUnix = (args, bundle, companions) => {
|
|
7087
7450
|
const system = flagBool(args, "system");
|
|
7088
|
-
const libDir =
|
|
7089
|
-
const binDir =
|
|
7451
|
+
const libDir = resolve9(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
|
|
7452
|
+
const binDir = resolve9(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
|
|
7090
7453
|
const link = join22(binDir, "lecodes");
|
|
7091
7454
|
try {
|
|
7092
7455
|
copyBundle(bundle, libDir, companions);
|
|
@@ -7096,7 +7459,7 @@ var installUnix = (args, bundle, companions) => {
|
|
|
7096
7459
|
throw permError(e, libDir, system);
|
|
7097
7460
|
}
|
|
7098
7461
|
try {
|
|
7099
|
-
|
|
7462
|
+
mkdirSync15(binDir, { recursive: true });
|
|
7100
7463
|
rmSync3(link, { force: true });
|
|
7101
7464
|
symlinkSync(join22(libDir, "lecodes"), link);
|
|
7102
7465
|
} catch (e) {
|
|
@@ -7119,7 +7482,7 @@ var appendPathToShellRc = (binDir) => {
|
|
|
7119
7482
|
const { file, name } = shellRc();
|
|
7120
7483
|
const line = `export PATH="${binDir}:$PATH"`;
|
|
7121
7484
|
try {
|
|
7122
|
-
const existing =
|
|
7485
|
+
const existing = existsSync21(file) ? readFileSync22(file, "utf8") : "";
|
|
7123
7486
|
if (existing.includes(line))
|
|
7124
7487
|
return name;
|
|
7125
7488
|
appendFileSync2(file, `${existing && !existing.endsWith(`
|
|
@@ -7157,7 +7520,7 @@ var finishPathUnix = (args, binDir) => {
|
|
|
7157
7520
|
log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
|
|
7158
7521
|
};
|
|
7159
7522
|
var installWindows = (args, bundle, companions) => {
|
|
7160
|
-
const target =
|
|
7523
|
+
const target = resolve9(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
|
|
7161
7524
|
try {
|
|
7162
7525
|
copyBundle(bundle, target, companions);
|
|
7163
7526
|
} catch (e) {
|
|
@@ -7194,9 +7557,9 @@ var addToUserPathWindows = (dir) => {
|
|
|
7194
7557
|
|
|
7195
7558
|
// src/index.ts
|
|
7196
7559
|
var STANDALONE = (() => {
|
|
7197
|
-
const dir =
|
|
7560
|
+
const dir = dirname15(process.execPath);
|
|
7198
7561
|
const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
|
|
7199
|
-
return
|
|
7562
|
+
return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
|
|
7200
7563
|
})();
|
|
7201
7564
|
var INSTALLED = STANDALONE && (() => {
|
|
7202
7565
|
const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
|
|
@@ -7212,7 +7575,7 @@ var INSTALLED = STANDALONE && (() => {
|
|
|
7212
7575
|
return false;
|
|
7213
7576
|
try {
|
|
7214
7577
|
const cand = join23(d, exe);
|
|
7215
|
-
return
|
|
7578
|
+
return existsSync22(cand) && realpathSync2(cand) === self;
|
|
7216
7579
|
} catch {
|
|
7217
7580
|
return false;
|
|
7218
7581
|
}
|
|
@@ -7275,12 +7638,28 @@ ${c.bold("Commands:")}
|
|
|
7275
7638
|
-o, --out <file> write JSON to a file instead of stdout
|
|
7276
7639
|
--png [file] rasterize a PNG (default screenshot.png;
|
|
7277
7640
|
needs @napi-rs/canvas)
|
|
7641
|
+
--script <file> drive the app through a step list (tap/type/
|
|
7642
|
+
scroll/wait/expect/screenshot/json steps)
|
|
7643
|
+
--out-dir <dir> where --script step outputs land (default .)
|
|
7278
7644
|
--scale <n> PNG device-pixel scale (default 2)
|
|
7279
7645
|
--width/--height viewport size (default 390x844)
|
|
7280
7646
|
--entry <path> override the entrypoint
|
|
7281
7647
|
--settle <ms> wait for promises/timers/fetch before snapshot
|
|
7282
7648
|
--wait-network let data fetches hit the real network
|
|
7283
7649
|
--logs forward the app's console output (to stderr)
|
|
7650
|
+
test [name…] Run headless flow tests: tests/*.flow.json scenarios, plus flows
|
|
7651
|
+
derived from the design graph (design/meta.json activator edges
|
|
7652
|
+
— checks the built app against the designed navigation; needs
|
|
7653
|
+
named elements + screen roots). Failing steps dump the frame
|
|
7654
|
+
(JSON + PNG) under tests/.artifacts/<flow>/.
|
|
7655
|
+
--flows run ONLY the design-derived flows
|
|
7656
|
+
--no-flows run ONLY the tests/*.flow.json files
|
|
7657
|
+
--json machine-readable report on stdout
|
|
7658
|
+
--dir <folder> tests folder (default tests/)
|
|
7659
|
+
--design-dir <dir> design folder (default design/)
|
|
7660
|
+
--artifacts <dir> failure-artifact folder override
|
|
7661
|
+
(+ the render timing flags: --width/--height, --settle,
|
|
7662
|
+
--device, --safe-area, --wait-network, --logs, --entry)
|
|
7284
7663
|
design [serve] LeCodes Design — a live canvas of screens for AI-driven prototyping
|
|
7285
7664
|
(needs the optional 'lecodes-design' package; works inside a
|
|
7286
7665
|
cloned project or standalone in any folder)
|
|
@@ -7313,7 +7692,8 @@ ${c.bold("Commands:")}
|
|
|
7313
7692
|
keeps heavy assets streaming from the server), pick the SDK
|
|
7314
7693
|
variant from the bundle's engine use, vendor app.json
|
|
7315
7694
|
plugins (pinned in plugins.lock.json), regenerate the
|
|
7316
|
-
CLI-owned shell files
|
|
7695
|
+
CLI-owned shell files. app.json "update": true (or
|
|
7696
|
+
{"url": …}) enables native OTA JS updates at launch
|
|
7317
7697
|
app plugin <list|add|remove> Manage native plugins in app.json (then run 'lecodes app sync')
|
|
7318
7698
|
add takes a known id (camera, qr-scanner) or github:owner/repo[@tag]
|
|
7319
7699
|
app open [ios] Open the native shell in its IDE (Xcode; Android Studio later)
|
|
@@ -7328,7 +7708,7 @@ ${c.bold("Commands:")}
|
|
|
7328
7708
|
ASC API key; stored in ~/.lecodes/asc.json
|
|
7329
7709
|
|
|
7330
7710
|
Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
|
|
7331
|
-
var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev, app };
|
|
7711
|
+
var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, design, scene, dev, app };
|
|
7332
7712
|
if (STANDALONE)
|
|
7333
7713
|
commands.install = install;
|
|
7334
7714
|
var readVersion = () => package_default.version ?? "unknown";
|
|
@@ -7340,15 +7720,15 @@ var OPTIONAL_PEERS = [
|
|
|
7340
7720
|
var peerVersion = (name, probe) => {
|
|
7341
7721
|
try {
|
|
7342
7722
|
const require2 = createRequire4(import.meta.url);
|
|
7343
|
-
let dir =
|
|
7723
|
+
let dir = dirname15(require2.resolve(probe));
|
|
7344
7724
|
for (let depth = 0;depth < 16; depth++) {
|
|
7345
7725
|
const manifest = join23(dir, "package.json");
|
|
7346
|
-
if (
|
|
7347
|
-
const parsed = JSON.parse(
|
|
7726
|
+
if (existsSync22(manifest)) {
|
|
7727
|
+
const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
|
|
7348
7728
|
if (parsed.name === name)
|
|
7349
7729
|
return parsed.version ?? "unknown";
|
|
7350
7730
|
}
|
|
7351
|
-
const parent =
|
|
7731
|
+
const parent = dirname15(dir);
|
|
7352
7732
|
if (parent === dir)
|
|
7353
7733
|
break;
|
|
7354
7734
|
dir = parent;
|