-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.js
More file actions
1426 lines (1332 loc) · 62.9 KB
/
Copy pathcli.js
File metadata and controls
1426 lines (1332 loc) · 62.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// tinyjs CLI: scaffold, run, and package tinyjs projects.
//
// tinyjs new <dir> scaffold a new app
// tinyjs dev run the app in the current directory
// tinyjs build build dist/<name> + dist/<Name>.app
// tinyjs update self-update from the latest GitHub release
// tinyjs uninstall remove ~/.tinyjs and the PATH symlink
//
// Runs on txiki.js itself (via the `tinyjs` wrapper script).
const enc = new TextEncoder();
const dec = new TextDecoder();
// txiki has no tjs.platform; OS=Windows_NT is always set by Windows itself,
// and navigator.platform reads "Linux …" from uname on Linux.
const IS_WIN = tjs.env.OS === 'Windows_NT';
const IS_LINUX = !IS_WIN && /linux/i.test(globalThis.navigator?.platform ?? '');
// URL.pathname renders C:\Users\me as /C:/Users/me (percent-encoded) — decode
// and drop the leading slash so the result is a usable Windows path.
function pathFromUrl(u) {
let p = decodeURIComponent(u.pathname);
if (/^\/[A-Za-z]:\//.test(p)) p = p.slice(1);
return p;
}
const TOOL_DIR = pathFromUrl(new URL('.', import.meta.url));
// Invoked as: tjs run cli.js <cmd> [args...]
const [cmd, ...args] = tjs.args.slice(3);
function fail(msg) {
console.log('tinyjs: ' + msg);
tjs.exit(1);
}
async function exists(p) {
try {
await tjs.stat(p);
return true;
} catch {
return false;
}
}
async function copyTree(src, dest) {
await tjs.makeDir(dest, { recursive: true }).catch(() => {});
const iter = await tjs.readDir(src);
for await (const e of iter) {
const s = src + '/' + e.name;
const d = dest + '/' + e.name;
if (e.isDirectory) await copyTree(s, d);
else await tjs.writeFile(d, await tjs.readFile(s));
}
}
// Portable rm -rf (replaces shelling out, which Windows has no equivalent for).
async function rmTree(p) {
try {
await tjs.remove(p, { recursive: true });
return;
} catch {}
try {
const st = await tjs.stat(p);
if (st.isDirectory) {
const iter = await tjs.readDir(p);
for await (const e of iter) await rmTree(p + '/' + e.name);
}
await tjs.remove(p);
} catch {}
}
async function copyFile(src, dest) {
await tjs.writeFile(dest, await tjs.readFile(src));
}
// Run a shell command line: sh -c on POSIX, cmd /c on Windows (also the only
// way to reach npm/npx there — they are .cmd shims CreateProcess won't exec).
const shellArgv = (cmdline) => IS_WIN ? ['cmd', '/c', cmdline] : ['sh', '-c', cmdline];
// npm/npx-style argv: pass through on POSIX, hop through cmd /c on Windows.
const nodeToolArgv = (argv) => IS_WIN ? ['cmd', '/c', ...argv] : argv;
// Give coding agents working in a scaffolded project the tinyjs reference
// skill — in .claude/skills/ (Claude Code) and .agents/skills/ (the
// tool-agnostic location other agents read).
async function writeAgentSkill(dir) {
// The whole skill dir: SKILL.md plus references/ (loaded on demand by the
// agent, so the core stays small while the deep material rides along).
for (const base of ['/.claude/skills/tinyjs', '/.agents/skills/tinyjs']) {
await copyTree(TOOL_DIR + 'skill', dir + base);
}
}
async function run(argv, opts = {}) {
const p = tjs.spawn(argv, { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', ...opts });
const st = await p.wait();
if (st.exit_status !== 0 || st.term_signal) {
fail(`command failed (${argv[0]}): ` + JSON.stringify(st));
}
}
// Run a command and capture its stdout.
async function runCapture(argv) {
const p = tjs.spawn(argv, { stdout: 'pipe', stderr: 'ignore' });
const reader = p.stdout.getReader();
let out = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
out += dec.decode(value, { stream: true });
}
const st = await p.wait();
if (st.exit_status !== 0 || st.term_signal) fail(`command failed (${argv[0]})`);
return out;
}
// Like run(), but returns false on failure instead of aborting.
async function tryRun(argv, opts = {}) {
const p = tjs.spawn(argv, { stdin: 'inherit', stdout: 'ignore', stderr: 'ignore', ...opts });
const st = await p.wait();
return st.exit_status === 0 && !st.term_signal;
}
// Capture stdout, tolerating a non-zero exit (returns whatever was printed).
async function capture(argv, opts = {}) {
const p = tjs.spawn(argv, { stdout: 'pipe', stderr: 'ignore', ...opts });
const reader = p.stdout.getReader();
let out = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
out += dec.decode(value, { stream: true });
}
await p.wait();
return out;
}
// --- self-update -------------------------------------------------------------
const REPO = 'tarwin/tinyjsapp';
const UPDATE_CHECK_FILE = TOOL_DIR + '.update-check';
const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000;
async function toolVersion() {
try {
return dec.decode(await tjs.readFile(TOOL_DIR + 'VERSION')).trim();
} catch {
return 'dev';
}
}
function parseVer(v) {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v));
return m ? [+m[1], +m[2], +m[3]] : null;
}
// True when `latest` is a release newer than `current`.
function isNewer(current, latest) {
const a = parseVer(current), b = parseVer(latest);
if (!a || !b) return false;
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
return false;
}
function withTimeout(promise, ms) {
return new Promise((resolve) => {
const t = setTimeout(() => resolve(null), ms);
promise.then(
(v) => { clearTimeout(t); resolve(v); },
() => { clearTimeout(t); resolve(null); },
);
});
}
async function fetchLatestVersion() {
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
headers: { 'user-agent': 'tinyjs-cli', accept: 'application/vnd.github+json' },
});
if (!res.ok) return null;
const tag = (await res.json()).tag_name;
return typeof tag === 'string' && parseVer(tag) ? tag : null;
} catch {
return null;
}
}
// Once a day (and never from a source checkout), see if a newer release
// exists and mention it. Network errors are silent; never blocks or fails
// the command it piggybacks on.
async function maybeNotifyUpdate() {
try {
const current = await toolVersion();
if (current === 'dev') return;
let cache = null;
try {
cache = JSON.parse(dec.decode(await tjs.readFile(UPDATE_CHECK_FILE)));
} catch {}
const notify = (latest) =>
console.log(`tinyjs: ${latest} is available (you have ${current}) — run \`tinyjs update\``);
if (cache?.latest && isNewer(current, cache.latest)) notify(cache.latest);
if (cache && Date.now() - (cache.checkedAt || 0) < UPDATE_CHECK_INTERVAL) return;
const latest = await withTimeout(fetchLatestVersion(), 4000);
if (!latest) return;
await tjs.writeFile(UPDATE_CHECK_FILE,
enc.encode(JSON.stringify({ checkedAt: Date.now(), latest })));
if (isNewer(current, latest) && latest !== cache?.latest) notify(latest);
} catch {}
}
async function cmdUpdate() {
const current = await toolVersion();
if (current === 'dev') {
fail('running from a source checkout — update with `git pull` (+ ' +
(IS_WIN ? 'setup.ps1' : './setup.sh') + ') instead');
}
const latest = await withTimeout(fetchLatestVersion(), 10000);
if (!latest) fail('could not reach GitHub to check the latest release');
if (!isNewer(current, latest)) {
console.log(`already up to date (${current})`);
tjs.exit(0);
}
if (args[0] === '--check') {
console.log(`${latest} is available (you have ${current}) — run \`tinyjs update\` to install`);
tjs.exit(0);
}
console.log(`==> updating ${current} → ${latest}`);
// The installer re-resolves "latest", verifies checksums, and swaps the
// install dir (~/.tinyjs / %LOCALAPPDATA%\tinyjs, or $TINYJS_HOME) in place.
if (IS_WIN) {
await run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass',
'-Command', 'irm https://tinyjs.app/install.ps1 | iex']);
} else {
await run(['sh', '-c', 'curl -fsSL https://tinyjs.app/install | sh']);
}
tjs.exit(0);
}
// Read a symlink's target (absolute or relative), or null if p isn't a symlink.
async function readLink(p) {
const t = (await capture(['readlink', p])).trim();
return t || null;
}
// Ask a yes/no question on the controlling terminal. No tty → treat as "no",
// so a piped/non-interactive `tinyjs uninstall` never deletes without --yes.
async function confirmTty(prompt) {
const out = (await capture(['sh', '-c',
'printf "%s" "$1" >/dev/tty 2>/dev/null && IFS= read -r r </dev/tty && printf "%s" "$r"',
'sh', prompt])).trim();
return /^y(es)?$/i.test(out);
}
async function cmdUninstall() {
if (IS_WIN) {
fail('there is no Windows installer yet — delete the checkout and remove it from your user PATH (Settings > Environment Variables; setup.ps1 added it)');
}
const version = await toolVersion();
if (version === 'dev') {
fail('running from a source checkout — nothing to uninstall (just delete the repo)');
}
// The install dir is the directory this CLI runs from — the installer created
// it as ${TINYJS_HOME:-~/.tinyjs}. Trust the running location over the env so
// we remove exactly the install that's executing.
const installDir = TOOL_DIR.replace(/\/+$/, '');
// Safety rails: never rm -rf a root or a git checkout.
if (!installDir || installDir === '/' || await exists(installDir + '/.git')) {
fail(`refusing to remove ${installDir || '/'} — does not look like a tinyjs install`);
}
// Collect PATH symlinks that point back into this install dir (the same
// candidate dirs the installer picks from).
const home = tjs.env.HOME || '';
const wrapper = installDir + '/tinyjs';
const links = [];
for (const d of ['/usr/local/bin', '/opt/homebrew/bin', home + '/.local/bin']) {
const link = d + '/tinyjs';
const target = await readLink(link);
if (!target) continue;
// Resolve a relative target against the link's own directory.
const abs = target.startsWith('/') ? target : d + '/' + target;
if (abs === wrapper) links.push(link);
}
console.log(`this will remove tinyjs ${version}:`);
console.log(' ' + installDir + ' (runtime + CLI)');
for (const l of links) console.log(' ' + l + ' (PATH symlink)');
if (!links.length) {
console.log(' (no PATH symlink found pointing here — remove any stray one by hand)');
}
const yes = args.some(a => a === '-y' || a === '--yes' || a === '-f' || a === '--force');
if (!yes && !(await confirmTty('\nremove it? [y/N] '))) {
console.log('cancelled');
tjs.exit(0);
}
for (const l of links) await run(['rm', '-f', l]);
await run(['rm', '-rf', installDir]);
console.log(`\nuninstalled tinyjs ${version}.`);
console.log('if the installer added a PATH line to your shell profile, delete the');
console.log('block marked "# added by tinyjs installer" (harmless if left).');
tjs.exit(0);
}
// Per-OS config: root keys apply everywhere, and an optional "macos" /
// "windows" / "linux" block is merged on top for that platform only. Block
// names match tiny.system.os() so the config vocabulary matches the runtime's.
// Plain objects merge (so "macos": { chrome: { vibrancy } } keeps a root
// chrome.frame); scalars and arrays replace outright.
const OS_KEYS = ['macos', 'windows', 'linux'];
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
function mergeConfig(base, over) {
const out = { ...base };
for (const [k, v] of Object.entries(over ?? {})) {
out[k] = isPlainObject(v) && isPlainObject(base[k]) ? mergeConfig(base[k], v) : v;
}
return out;
}
// Env vars OVERRIDE the file — but never silently: a value exported in a shell
// quietly changing how a project signs is exactly the failure worth shouting
// about, so an override that displaces a real config value announces itself.
// [cfg path, env var] pairs; the path is dotted.
const ENV_OVERRIDES = [
['signIdentity', 'TINYJS_SIGN_IDENTITY'],
['notarize.profile', 'TINYJS_NOTARY_PROFILE'],
];
function applyEnvOverrides(cfg) {
for (const [path, envName] of ENV_OVERRIDES) {
const val = tjs.env[envName];
if (!val) continue;
const parts = path.split('.');
let node = cfg;
for (const p of parts.slice(0, -1)) {
if (!isPlainObject(node[p])) node[p] = {};
node = node[p];
}
const leaf = parts[parts.length - 1];
const had = node[leaf];
node[leaf] = val;
if (had !== undefined && had !== val) {
console.log(`==> ${path} from ${envName} (overriding tinyjs.json)`);
}
}
return cfg;
}
// An app can declare the oldest tinyjs it works with. Optional — but without
// it, running an app against a tinyjs that predates an API it uses fails as an
// unexplained TypeError in the page, with nothing naming the real cause.
// A source checkout reports 'dev' and is assumed newest, so it never trips.
async function checkMinVersion(cfg) {
const want = cfg.minTinyjsVersion;
if (!want) return;
const have = await toolVersion();
if (have === 'dev') return;
if (!parseVer(want)) return; // unparseable: not worth blocking a build over
if (isNewer(have, want)) {
fail(`${cfg.name} needs tinyjs ${want} or newer — you have ${have}.\n` +
' run `tinyjs update`, or drop "minTinyjsVersion" from tinyjs.json ' +
'if you know better.');
}
}
async function loadConfig() {
if (!(await exists('tinyjs.json'))) {
fail('no tinyjs.json here — run this from a tinyjs project (or `tinyjs new <dir>` to create one)');
}
const raw = JSON.parse(dec.decode(await tjs.readFile('tinyjs.json')));
// Resolve the platform ONCE here, so every existing cfg.* read downstream
// sees the merged value and no call site has to know about OS blocks.
const osKey = IS_WIN ? 'windows' : IS_LINUX ? 'linux' : 'macos';
const cfg = applyEnvOverrides(mergeConfig(raw, raw[osKey]));
for (const k of OS_KEYS) delete cfg[k]; // the blocks themselves aren't config
if (!cfg.name) fail('tinyjs.json needs a "name"');
if (cfg.activation && !['regular', 'accessory'].includes(cfg.activation)) {
fail('tinyjs.json "activation" must be "regular" or "accessory"');
}
await checkMinVersion(cfg);
return { title: cfg.name, size: '960x640', id: 'com.example.' + cfg.name, ...cfg };
}
// Backend entry: cfg.backend, or the first of src/main.js|ts, backend/main.js|ts.
// .ts entries (or any entry with cfg.backendBundle) are bundled with esbuild —
// which also makes npm packages usable in the backend.
async function resolveBackendEntry(cfg) {
if (cfg.backend) {
if (!(await exists(cfg.backend))) fail('backend entry not found: ' + cfg.backend);
return cfg.backend;
}
for (const p of ['src/main.js', 'src/main.ts', 'backend/main.js', 'backend/main.ts']) {
if (await exists(p)) return p;
}
fail('no backend entry found (src/main.js|ts, backend/main.js|ts, or "backend" in tinyjs.json)');
}
// Generate .build/app/: bridge + copied backend sources + an entry module,
// in the layout `tjs app compile` expects (app dir with an app.json manifest
// — it bundles the whole module graph into one executable). The frontend
// ships as real files (the launcher loads file:// documents, so relative
// css/js/images just work): dev points at src/frontend directly; build
// copies it into .build/app/frontend.
async function generateBuild(cfg, dev = false) {
await rmTree('.build');
const B = '.build/app';
await tjs.makeDir(B, { recursive: true });
await tjs.writeFile(B + '/app.json',
enc.encode(JSON.stringify({ version: 0, build: {}, main: 'entry.js' })));
await tjs.writeFile(B + '/bridge.js', await tjs.readFile(TOOL_DIR + 'runtime/bridge.js'));
await tjs.writeFile(B + '/update.js', await tjs.readFile(TOOL_DIR + 'runtime/update.js'));
// Backend: TypeScript (or any entry, with npm packages) bundles via esbuild;
// plain JS copies sources as before.
const backendEntry = await resolveBackendEntry(cfg);
const entryDir = backendEntry.includes('/') ? backendEntry.replace(/\/[^/]*$/, '') : '.';
let entryName = backendEntry.split('/').pop();
if (backendEntry.endsWith('.ts')) {
console.log('==> bundling backend (esbuild)');
await run(nodeToolArgv(['npx', '--yes', 'esbuild', backendEntry, '--bundle', '--format=esm',
'--platform=neutral', '--main-fields=module,main',
'--external:tjs:*', '--log-level=warning',
'--outfile=' + B + '/src/main.js']));
entryName = 'main.js';
} else {
// Copy the backend dir (minus a nested frontend/, for src/ layouts).
await tjs.makeDir(B + '/src');
const iter = await tjs.readDir(entryDir);
for await (const e of iter) {
if (e.name === 'frontend') continue;
const s = entryDir + '/' + e.name;
const d = B + '/src/' + e.name;
if (e.isDirectory) await copyTree(s, d);
else await tjs.writeFile(d, await tjs.readFile(s));
}
}
// tinyjs.json "inject": document-start JS injected into every window before
// the page boots (site wrappers shim capabilities/behaviour there). Bundled
// like the backend (.ts via esbuild); ships as Resources/app/inject.js in a
// packaged .app, rides the spawn env (TINYJS_INJECT) in dev.
let injectSrc = null;
if (cfg.inject) {
if (!(await exists(cfg.inject))) fail('tinyjs.json "inject": ' + cfg.inject + ' not found');
if (String(cfg.inject).endsWith('.ts')) {
console.log('==> bundling inject (esbuild)');
await run(nodeToolArgv(['npx', '--yes', 'esbuild', cfg.inject, '--bundle',
'--format=iife', '--log-level=warning',
'--outfile=' + B + '/inject.js']));
injectSrc = dec.decode(await tjs.readFile(B + '/inject.js'));
} else {
injectSrc = dec.decode(await tjs.readFile(cfg.inject));
await tjs.writeFile(B + '/inject.js', enc.encode(injectSrc));
}
}
// Frontend: optional build hook (bundlers) or plain source dir; dev with a
// devUrl skips all of this — the dev server owns the frontend. A "url"
// wrapper (the main window IS a remote page) needs no frontend at all.
const fe = cfg.frontend ?? {};
const devUrl = dev ? fe.devUrl : null;
let frontendSrc = fe.dir ?? 'src/frontend';
if (!dev && fe.build) {
console.log('==> frontend build: ' + fe.build);
await run(shellArgv(fe.build));
frontendSrc = fe.dist ?? 'dist';
}
if (!devUrl && !cfg.url && !(await exists(frontendSrc + '/index.html'))) {
fail(frontendSrc + '/index.html not found');
}
if (!dev && (await exists(frontendSrc + '/index.html'))) await copyTree(frontendSrc, B + '/frontend');
// Frontend location at runtime: dev uses the project sources in place;
// packaged apps resolve relative to entry.js (.app Resources/app/) with a
// fallback next to the executable for the bare compiled binary, where
// import.meta.url throws.
const frontendResolver = devUrl
? `const FRONTEND = ${JSON.stringify(devUrl)};`
: dev
? `const FRONTEND = ${JSON.stringify(tjs.cwd + '/' + frontendSrc)};`
: `let FRONTEND;
try {
FRONTEND = decodeURIComponent(new URL('./frontend', import.meta.url).pathname);
if (/^\\/[A-Za-z]:\\//.test(FRONTEND)) FRONTEND = FRONTEND.slice(1); // windows /C:/…
} catch { FRONTEND = tjs.exePath.replace(/[\\\\/][^\\\\/]*$/, '') + '/frontend'; }`;
let entry = `import { createApp } from './bridge.js';
import * as appMod from './src/${entryName}';
${frontendResolver}
const app = await createApp({
htmlPath: ${devUrl ? 'FRONTEND' : "FRONTEND + '/index.html'"},
title: ${JSON.stringify(cfg.title)},
size: ${JSON.stringify(cfg.size)},
version: ${JSON.stringify(cfg.version || '0.0.0')},
tinyjsVersion: ${JSON.stringify(await toolVersion())},
id: ${JSON.stringify(cfg.id)},
api: appMod.api ?? {},
onMenu: appMod.onMenu,
onTray: appMod.onTray,
onHotkey: appMod.onHotkey,
onContextMenu: appMod.onContextMenu,
onSystem: appMod.onSystem,
onOpenUrl: appMod.onOpenUrl,
onOpenFiles: appMod.onOpenFiles,
onNotificationClick: appMod.onNotificationClick,
onNotificationAction: appMod.onNotificationAction,
onMediaKey: appMod.onMediaKey,
onWindowClosed: appMod.onWindowClosed,
onWindowState: appMod.onWindowState,
onClipboardChange: appMod.onClipboardChange,
onUpdateAvailable: appMod.onUpdateAvailable,
onAudioTap: appMod.onAudioTap,
onNavigate: appMod.onNavigate,
onDownload: appMod.onDownload,
onWindowOpen: appMod.onWindowOpen,
url: ${JSON.stringify(cfg.url ?? null)},
downloads: ${JSON.stringify(cfg.downloads ?? null)},
popups: ${JSON.stringify(cfg.popups ?? null)},
apiAccess: ${JSON.stringify(cfg.api ?? null)},
inject: ${JSON.stringify(injectSrc)},
chrome: ${JSON.stringify(cfg.chrome ?? null)},
update: ${JSON.stringify(cfg.update ?? null)},
activation: ${JSON.stringify(cfg.activation ?? null)},
readAccess: ${JSON.stringify(cfg.readAccess ?? null)},
userAgent: ${JSON.stringify(cfg.userAgent ?? null)},
audioTap: ${JSON.stringify(cfg.audioTap ?? null)},
windowPlacement: ${JSON.stringify(cfg.windowPlacement ?? null)},
contextMenu: ${JSON.stringify(cfg.contextMenu ?? true)},
browserAccelerators: ${JSON.stringify(cfg.browserAccelerators ?? false)},
debug: ${JSON.stringify(cfg.debug ?? false)},
about: ${JSON.stringify(cfg.about ?? null)},
urlScheme: ${JSON.stringify(cfg.urlScheme ?? null)},
fileExtensions: ${JSON.stringify(cfg.fileExtensions ?? null)},
openFolders: ${JSON.stringify(cfg.openFolders ?? false)},
permissions: ${JSON.stringify(cfg.permissions ?? null)},
offscreenRescue: ${JSON.stringify(cfg.offscreenRescue ?? null)},
});
if (appMod.init) appMod.init(app);
`;
if (dev && !devUrl && !cfg.url) {
// Hot-reload: any frontend change re-renders the page from disk in place.
// "url" apps skip it twice over: the main window is a remote page, and
// the frontend dir being watched may not even exist.
entry += `
let reloadTimer = null;
tjs.watch(FRONTEND, () => {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(async () => {
try {
await app.reload();
console.log('tinyjs: frontend reloaded');
} catch (e) {
console.log('tinyjs: frontend reload failed:', String(e));
}
}, 150);
});
`;
}
entry += `
await app.done;
tjs.exit(0);
`;
await tjs.writeFile(B + '/entry.js', enc.encode(entry));
return B;
}
// Overlay tinyjs onto a fresh create-vite scaffold: config wired for the dev
// server + build hook, a backend dir, ambient types, and the agent skill.
// Vite's own templates stay current upstream — we never fork them.
async function scaffoldViteTemplate(dir, name, template) {
console.log('==> npm create vite (' + template + ')');
await run(nodeToolArgv(['npm', 'create', 'vite@latest', dir, '--yes', '--', '--template', template]));
const ts = template.endsWith('-ts');
const backendEntry = 'backend/main.' + (ts ? 'ts' : 'js');
await tjs.writeFile(dir + '/tinyjs.json', enc.encode(JSON.stringify({
name,
title: name,
size: '960x640',
id: 'com.example.' + name,
version: '0.1.0',
backend: backendEntry,
frontend: {
build: 'npm run build',
dist: 'dist',
dev: 'npm run dev',
devUrl: 'http://127.0.0.1:5173',
},
}, null, 2) + '\n'));
await tjs.makeDir(dir + '/backend', { recursive: true });
await tjs.writeFile(dir + '/' + backendEntry, enc.encode(
`// tinyjs backend — full system access via txiki.js (the 'tjs' global).
// Every api method is callable from the page: await tiny.api.call('hello', {...})
export const api${ts ? ': Record<string, TinyApiHandler>' : ''} = {
hello: async ({ name }${ts ? ': { name: string }' : ''}) => 'hi ' + name + ' — from the backend',
};
export function init(app${ts ? ': TinyApp' : ''}) {
// window is up; push events with app.push('event', data)
}
`));
// Ambient types where each side's editor picks them up automatically.
for (const d of ['/src', '/backend']) {
await tjs.writeFile(dir + d + '/tiny.d.ts', await tjs.readFile(TOOL_DIR + 'template/types/tiny.d.ts'));
await tjs.writeFile(dir + d + '/tjs.d.ts', await tjs.readFile(TOOL_DIR + 'template/types/tjs.d.ts'));
}
// backend/ needs its own project file: the Vite tsconfig only covers src/,
// and TS "inferred projects" don't load sibling ambient .d.ts files.
const beConfig = {
compilerOptions: {
target: 'es2022',
module: 'es2022',
moduleResolution: 'bundler',
strict: ts,
checkJs: false,
noEmit: true,
types: [],
},
include: ['./**/*'],
};
await tjs.writeFile(dir + '/backend/' + (ts ? 'tsconfig.json' : 'jsconfig.json'),
enc.encode(JSON.stringify(beConfig, null, 2) + '\n'));
// package.json: pin the dev port and make built asset paths relative
// (file:// documents need base './').
const pkgPath = dir + '/package.json';
const pkg = JSON.parse(dec.decode(await tjs.readFile(pkgPath)));
pkg.scripts = pkg.scripts ?? {};
// --host 127.0.0.1: modern node binds ::1 for 'localhost', which txiki's
// IPv4 fetch (our readiness probe) can't reach.
pkg.scripts.dev = 'vite --host 127.0.0.1 --port 5173 --strictPort';
pkg.scripts.build = String(pkg.scripts.build ?? 'vite build')
.replace('vite build', 'vite build --base=./');
await tjs.writeFile(pkgPath, enc.encode(JSON.stringify(pkg, null, 2) + '\n'));
await tjs.writeFile(dir + '/icon.png', await tjs.readFile(TOOL_DIR + 'template/icon.png'));
await writeAgentSkill(dir);
console.log(`created ${dir}/ (${template} + tinyjs)
cd ${dir}
npm install
tinyjs dev # vite dev server + native window, HMR included
tinyjs build # vite build + package .app`);
}
async function cmdNew() {
const dir = args[0];
if (!dir) fail('usage: tinyjs new <dir> [--template vanilla|vanilla-ts|react|react-ts|vue|vue-ts|svelte|svelte-ts|solid|solid-ts]');
if (await exists(dir)) fail(`'${dir}' already exists`);
const name = dir.replace(/\/+$/, '').split('/').pop();
const ti = args.indexOf('--template');
if (ti !== -1) {
const template = (args[ti + 1] ?? '').replace(/^=/, '');
if (!template) fail('--template needs a value (e.g. react-ts)');
await scaffoldViteTemplate(dir, name, template);
return;
}
await copyTree(TOOL_DIR + 'template', dir);
const cfgPath = dir + '/tinyjs.json';
let cfg = dec.decode(await tjs.readFile(cfgPath)).replaceAll('__NAME__', name);
// Record the tinyjs this app was made with, so running it on an older one
// says so instead of half-working. A source checkout has no version to
// stamp, so scaffolds from a checkout simply omit the key.
const stamp = await toolVersion();
if (parseVer(stamp)) {
const o = JSON.parse(cfg);
o.minTinyjsVersion = String(stamp).replace(/^v/, '');
cfg = JSON.stringify(o, null, 2) + '\n';
}
await tjs.writeFile(cfgPath, enc.encode(cfg));
await writeAgentSkill(dir);
console.log(`created ${dir}/
cd ${dir}
tinyjs dev # run it
tinyjs build # package it`);
}
// Dev-checkout convenience (Windows + Linux): if the native launcher sources
// (or the injected client, which is compiled into it) are newer than the
// built launcher, rebuild via setup.ps1 / setup.sh before starting — so
// hacking on tinyjs itself never runs a stale binary. Installed copies
// (VERSION file present) never rebuild.
async function ensureLauncherFresh() {
// macOS was excluded here, and it was the only platform where editing
// runtime/tiny.js left `tinyjs dev` silently running the PREVIOUS client —
// the page then gets an API that predates the app's own code, which
// surfaces as an unexplained TypeError rather than anything pointing at a
// stale binary. Same rule everywhere now.
if ((await toolVersion()) !== 'dev') return;
const exe = TOOL_DIR + 'native/' +
(IS_WIN ? 'launcher-win.exe' : IS_LINUX ? 'launcher-linux' : 'launcher-macos');
const srcs = IS_WIN
? ['native/launcher-win.cc', 'runtime/tiny.js']
: IS_LINUX
? ['native/launcher-linux.cc', 'runtime/tiny.js']
: ['native/launcher-macos.cc', 'runtime/tiny.js'];
const mtime = async (p) => {
try { return (await tjs.stat(p)).mtim.getTime(); } catch { return null; }
};
const built = await mtime(exe);
let stale = built === null;
for (const src of srcs) {
const m = await mtime(TOOL_DIR + src);
if (m !== null && (built === null || m > built)) stale = true;
}
if (!stale) return;
console.log('==> launcher sources changed — rebuilding (' + (IS_WIN ? 'setup.ps1' : 'setup.sh') + ')');
if (IS_WIN) {
await run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass',
'-File', TOOL_DIR + 'setup.ps1', '-SkipPath'], { cwd: TOOL_DIR });
} else {
await run(['sh', TOOL_DIR + 'setup.sh'], { cwd: TOOL_DIR });
}
}
async function cmdDev() {
const cfg = await loadConfig();
maybeNotifyUpdate(); // fire-and-forget; prints if a newer release exists
await ensureLauncherFresh();
// Frontend dev server (vite etc.): spawn it, wait until it responds, and
// point the window at it. HMR replaces tinyjs' own frontend watcher; the
// tiny.* bridge is injected into any origin, so it works over http too.
let devServer = null;
const fe = cfg.frontend ?? {};
if (fe.devUrl) {
if (fe.dev) {
console.log('==> starting frontend dev server: ' + fe.dev);
devServer = tjs.spawn(shellArgv(fe.dev), { stdout: 'inherit', stderr: 'inherit' });
}
// Probe both spellings: 'localhost' may be ::1-only (modern node) which
// txiki's fetch can't reach even when the server is up.
const probes = [fe.devUrl];
if (fe.devUrl.includes('//localhost')) probes.push(fe.devUrl.replace('//localhost', '//127.0.0.1'));
const deadline = Date.now() + 60000;
let up = false;
while (!up && Date.now() < deadline) {
for (const u of probes) {
try { await fetch(u, { method: 'HEAD' }); up = true; break; } catch {}
}
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
devServer?.kill();
fail('frontend dev server did not respond at ' + fe.devUrl);
}
}
// Frontend changes hot-reload inside the app process (see the dev entry);
// backend changes need a fresh process, so we watch and restart.
let child = null;
let restarting = false;
let restartTimer = null;
tjs.watch('src', (file) => {
if (!file || String(file).startsWith('frontend')) return;
clearTimeout(restartTimer);
restartTimer = setTimeout(() => {
console.log(`tinyjs: ${file} changed, restarting backend`);
restarting = true;
child?.kill();
}, 150);
});
while (true) {
const B = await generateBuild(cfg, true);
restarting = false;
// Absolute entry path with uniform native separators: txiki's Windows
// relative-import resolution breaks on a relative main module and on
// mixed / and \ in its path.
const entryPath = IS_WIN
? (tjs.cwd + '\\' + B.replace(/\//g, '\\') + '\\entry.js')
: tjs.cwd + '/' + B + '/entry.js';
// An explicit TINYJS_LAUNCHER in the environment wins (matches the
// bridge's own precedence) — useful for testing a different build.
const devEnv = { ...tjs.env, TINYJS_LAUNCHER: tjs.env.TINYJS_LAUNCHER || (TOOL_DIR + 'native/' + (IS_WIN ? 'launcher-win.exe' : IS_LINUX ? 'launcher-linux' : 'launcher-macos')) };
// Show the project's own icon while developing, on every platform: the
// titlebar/taskbar on Windows and Linux, the Dock tile on macOS. Without
// it a dev run wears the terminal's icon, which makes it indistinguishable
// from every other dev run you have open.
const iconSrc = cfg.icon || 'icon.png';
if (await exists(iconSrc)) devEnv.TINYJS_ICON = tjs.cwd + '/' + iconSrc;
// Linux: the app id names the WM class (window ↔ .desktop matching).
if (IS_LINUX) devEnv.TINYJS_APP_ID = cfg.id;
// Dev always has devtools (F12), whatever the manifest says — the
// launcher reads this env directly; the bridge only overrides it when
// the manifest raises debug to 'open'. Seeded as 'dev', NOT '1': every
// launcher treats any non-empty value as devtools-on, but the bridge's
// message trace fires only on an explicit user-set value ('1'/'open') —
// seeding '1' had every dev run spewing the full bridge log. Keep the
// sentinel short: launcher-win reads this into char[8].
if (!devEnv.TINYJS_DEBUG) devEnv.TINYJS_DEBUG = 'dev';
child = tjs.spawn([tjs.exePath, 'run', entryPath], {
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
env: devEnv,
});
const st = await child.wait();
if (!restarting) {
devServer?.kill();
tjs.exit(st.exit_status ?? 0);
}
}
}
// Installer disk image: the .app plus an /Applications shortcut. Kept as a
// helper so `notarize` can rebuild it from the STAPLED .app — a dmg made at
// build time holds the pre-staple bundle, so offline Gatekeeper can't find the
// ticket inside it.
async function makeDmg(cfg, APP) {
console.log('==> creating dmg');
const STAGE = '.build/dmg';
await run(['rm', '-rf', STAGE]);
await tjs.makeDir(STAGE, { recursive: true });
await run(['cp', '-R', APP, STAGE + '/']);
await run(['ln', '-s', '/Applications', STAGE + '/Applications']);
const dmg = 'dist/' + cfg.name + '-' + (cfg.version || '0.0.0') + '.dmg';
await run(['hdiutil', 'create', '-volname', cfg.title, '-srcfolder', STAGE,
'-ov', '-quiet', '-format', 'UDZO', dmg]);
console.log(' ' + dmg);
return dmg;
}
// `tinyjs build --cli [name]` writes a shim so the app is runnable from a
// terminal. It's a build artifact because only the build knows where the
// executable lands — and because argv now reaches onOpenFiles on every
// platform, the shim is genuinely just exec: no `open -a`, no --open mode, no
// per-platform branch.
//
// It goes in dist/bin/, NOT dist/ — the bare executable is already dist/<name>
// and a shim of the same name would overwrite it (which is exactly what the
// first version of this did). dist/bin/<name> also means the symlink onto PATH
// carries the right command name.
//
// It targets the bare dist/<name> backend, NOT the .app. Inside the bundle
// the main executable is the LAUNCHER, which parses argv as <html> <socket>
// and dies on a file path ("cannot connect to /tmp/x.md" — found the hard
// way). The backend is the binary that reads argv and hands paths to
// onOpenFiles.
//
// Caveat worth knowing on macOS: the bare binary has no single-instance pipe
// (that's Windows/Linux only — macOS normally gets it from LaunchServices),
// so running the shim while the app is already open starts a SECOND copy.
// Cold start is the case this covers well.
async function maybeWriteCliShim(cfg, appBundle) {
const i = args.indexOf('--cli');
if (i < 0) return;
const next = args[i + 1];
const name = next && !next.startsWith('--') ? next : cfg.name;
const isWin = tjs.env.OS === 'Windows_NT';
await tjs.makeDir('dist/bin', { recursive: true });
const shimPath = 'dist/bin/' + name + (isWin ? '.cmd' : '');
// ../ from dist/bin back to dist/
const target = isWin ? `%~dp0..\\${cfg.name}.exe` : `$DIR/../${cfg.name}`;
const body = isWin
? `@echo off\r\n"${target}" %*\r\n`
// exec, so signals and the exit code belong to the app rather than to a
// shell sitting in front of it. "$@" quoted keeps paths with spaces whole.
: `#!/bin/sh\n`
+ `# ${cfg.title} — run from a terminal. Files named here reach the app\n`
+ `# through onOpenFiles, whether it was already running or not.\n`
+ `DIR="$(cd "$(dirname "$0")" && pwd)"\n`
+ `exec "${target}" "$@"\n`;
await tjs.writeFile(shimPath, enc.encode(body));
if (!isWin) await tryRun(['chmod', '+x', shimPath]);
console.log(`==> cli shim: ${shimPath}`);
// Windows has no /usr/local/bin and no ln -sf; printing the Unix line there
// told users to run a command that cannot work.
if (isWin)
console.log(` put it on PATH: setx PATH "%PATH%;${tjs.cwd}\\dist\\bin"`);
else
console.log(` link it: ln -sf "$(pwd)/${shimPath}" /usr/local/bin/${name}`);
}
async function cmdBuild() {
const cfg = await loadConfig();
// Same staleness guard `dev` has, and it matters more here: a build SHIPS
// the launcher (dist/launcher.exe) and, on Windows, shells out to it to
// stamp the app icon — so a stale binary doesn't just run old code, it goes
// out in the release and can silently predate the --embed-icon flag it is
// being asked for. No-op unless this is a dev checkout.
await ensureLauncherFresh();
await generateBuild(cfg);
const cwd = tjs.cwd;
console.log('==> compiling backend');
await rmTree('dist');
await tjs.makeDir('dist');
// `tjs app compile` runs from the parent of the app/ dir and bundles the
// whole module graph into a standalone executable.
let compiler = tjs.exePath;
if (IS_LINUX) {
// The icon rides inside the compiled binary's TPK bundle (next to the
// frontend); the launcher reads it for the window icon and notifications.
const linIcon = cfg.icon || 'icon.png';
if (await exists(linIcon)) await copyFile(linIcon, '.build/app/icon.png');
}
if (IS_WIN) {
const winIcon = cfg.icon || 'icon.png';
if (await exists(winIcon)) {
console.log('==> embedding icon');
// The icon rides inside the exe, twice: at the TPK app root (the bridge
// hands it to the launcher for the window/taskbar) and as a PE resource
// (Explorer, shortcuts, DefaultIcon registry entries). `app compile`
// templates the RUNNING exe and the appended bundle rules out resource-
// editing the output — so stamp a clean copy of the runtime first and
// compile with that.
await copyFile(winIcon, '.build/app/icon.png');
compiler = cwd + '/.build/tjs-icon.exe';
await copyFile(tjs.exePath, compiler);
// Don't let this fail quietly: the exe still builds and runs without a
// PE icon, it just shows Explorer's generic one, so a swallowed failure
// reads as "Windows lost my icon" days later. A launcher-win.exe older
// than the --embed-icon flag exits non-zero here, as does an unreadable
// png or a resource update blocked by AV / an open handle.
// stderr inherited: embed_icon says nothing on success and names the
// actual reason on failure, which beats guessing from a generic warning.
if (!(await tryRun([TOOL_DIR + 'native/launcher-win.exe', '--embed-icon',
compiler, cwd + '/' + winIcon], { stderr: 'inherit' }))) {
console.log(' WARNING: could not embed ' + winIcon + ' into ' + cfg.name +
'.exe — it will show the default Windows icon.');
console.log(' (rebuild the launcher with setup.ps1 if it predates --embed-icon)');
}
}
}
await run([compiler, 'app', 'compile', cwd + '/dist/' + cfg.name], { cwd: cwd + '/.build' });
if (compiler !== tjs.exePath) await tjs.remove(compiler).catch(() => {});
if (IS_WIN) {
// Windows build: a portable dist/ folder — just <name>.exe (compiled
// backend; the frontend and icon ride inside its TPK bundle and extract
// to tmp at launch) and launcher.exe next to it (the bridge finds it
// there). No bundle/codesign step; zip the folder to distribute.
if (!(await exists('dist/' + cfg.name + '.exe')) && (await exists('dist/' + cfg.name))) {
await tjs.rename('dist/' + cfg.name, 'dist/' + cfg.name + '.exe');
}
// The tjs runtime is a console app; double-clicking a console exe flashes
// a terminal behind the window (and an attached console makes txiki treat
// stdin as interactive). Flip the PE subsystem to GUI in the header —
// stdout still works when a parent provides handles (tinyjs dev pipes).
{
const exePath = 'dist/' + cfg.name + '.exe';
const exe = await tjs.readFile(exePath);
const dv = new DataView(exe.buffer, exe.byteOffset, exe.byteLength);
const peOff = dv.getUint32(0x3c, true);
if (dv.getUint32(peOff, true) === 0x00004550 /* "PE\0\0" */) {
const subsystemOff = peOff + 24 + 68; // OptionalHeader + Subsystem
if (dv.getUint16(subsystemOff, true) === 3 /* console */) {
dv.setUint16(subsystemOff, 2 /* GUI */, true);
await tjs.writeFile(exePath, exe);
}
}
}
await copyFile(TOOL_DIR + 'native/launcher-win.exe', 'dist/launcher.exe');
// Stamp launcher.exe (a clean PE) too, so its process/window class gets
// the app icon.
const winIcon = cfg.icon || 'icon.png';
if (await exists(winIcon)) {
if (!(await tryRun([TOOL_DIR + 'native/launcher-win.exe', '--embed-icon',
tjs.cwd + '/dist/launcher.exe', tjs.cwd + '/' + winIcon],
{ stderr: 'inherit' }))) {
console.log(' WARNING: could not embed ' + winIcon + ' into launcher.exe.');
}
}
await maybeWriteCliShim(cfg, null);
console.log('==> done');
console.log(`run it: .\\dist\\${cfg.name}.exe`);
return;
}
if (IS_LINUX) {
// Linux build: a portable dist/ folder — <name> (compiled backend; the
// frontend and icon ride inside its TPK bundle) with launcher + icon.png
// next to it. The bridge registers a .desktop entry (app menu, deep
// links, file associations) on the app's first run — no install step.
await copyFile(TOOL_DIR + 'native/launcher-linux', 'dist/launcher');
await run(['chmod', '+x', 'dist/launcher', 'dist/' + cfg.name]);
const linIcon = cfg.icon || 'icon.png';
if (await exists(linIcon)) await copyFile(linIcon, 'dist/icon.png');
await maybeWriteCliShim(cfg, null);
console.log('==> done');
console.log(`run it: ./dist/${cfg.name}`);
return;
}
await run(['cp', TOOL_DIR + 'native/launcher-macos', 'dist/launcher']);
// The bare binary resolves its frontend next to the executable ("url"