@mosetta/cli 0.0.0-stage → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/bin/mosetta.mjs +141 -0
- package/package.json +21 -3
- package/src/buildArchivePacker.mjs +144 -0
- package/src/buildConfigLoader.mjs +48 -0
- package/src/buildWorker.mjs +11 -0
- package/src/deploy.mjs +154 -0
- package/src/dev.mjs +145 -0
- package/src/devBuilder.mjs +116 -0
- package/src/devKeys.mjs +71 -0
- package/src/devStaticDir.mjs +112 -0
- package/src/engineLoader.mjs +46 -0
- package/src/evaluate.mjs +18 -0
- package/src/host/nodeFileSystem.mjs +45 -0
- package/src/log.mjs +114 -0
- package/src/login.mjs +136 -0
- package/src/push.mjs +99 -0
- package/src/scanner.mjs +26 -0
- package/src/serverPackage.mjs +75 -0
- package/src/staticBuild.mjs +100 -0
- package/src/typecheck.mjs +107 -0
- package/src/version.mjs +66 -0
- package/src/wasmEngine.mjs +55 -0
- package/src/workerd.mjs +172 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { FileSystem } from '@mosetta/build/fileSystem';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Node implementation of the file port: the real FS, read-only.
|
|
6
|
+
*
|
|
7
|
+
* Resolution no longer lives here: `createRequire` contexts died together with the `resolve`
|
|
8
|
+
* method — packages are resolved by `PackagesResolver` from the scanner catalog, identically in
|
|
9
|
+
* all environments.
|
|
10
|
+
*/
|
|
11
|
+
export class NodeFileSystem extends FileSystem {
|
|
12
|
+
readFile(file, encoding = null) {
|
|
13
|
+
return encoding ? fs.readFileSync(file, encoding) : fs.readFileSync(file);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
exists(file) {
|
|
17
|
+
return fs.existsSync(file);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
readDir(dir) {
|
|
21
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
22
|
+
.map((e) => ({ name: e.name, isDirectory: e.isDirectory() }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
isDirectory(dir) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.statSync(dir).isDirectory();
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
realPath(file) {
|
|
34
|
+
return fs.realpathSync(file);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
mtimeMs(file) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.statSync(file).mtimeMs;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
}
|
package/src/log.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { formatBytes } from '@mosetta/build';
|
|
2
|
+
|
|
3
|
+
export { formatBytes };
|
|
4
|
+
|
|
5
|
+
// Sectioned CLI log — the single tool for all output sources.
|
|
6
|
+
// Every source ([cli], [typescript], [esbuild], [tiles], [workerd], [assets], ...) has a
|
|
7
|
+
// stable color; the HEADER prints once per run of lines from one source (source change → blank
|
|
8
|
+
// separator line + new header), the body starts on a new line with no prefixes — copying from
|
|
9
|
+
// the console does not drag `[typescript]` on every line. Body and header share the section
|
|
10
|
+
// color (header is bold); outside a TTY — plain text.
|
|
11
|
+
const PALETTE = {
|
|
12
|
+
cli: 36, // cyan — orchestration: build summaries, dev addresses
|
|
13
|
+
typescript: 34, // blue — tsc gate and the dev companion
|
|
14
|
+
esbuild: 31, // red — compile errors (syntax and other hard failures, not types)
|
|
15
|
+
tiles: 32, // green — the tiles being built: rebuilds, watcher, environment warnings
|
|
16
|
+
workerd: 35, // magenta — render process output
|
|
17
|
+
assets: 33, // yellow — build asset table
|
|
18
|
+
};
|
|
19
|
+
const FALLBACK = [96, 94, 92, 95, 93, 90];
|
|
20
|
+
|
|
21
|
+
let lastSection = null;
|
|
22
|
+
|
|
23
|
+
function colorOf(name) {
|
|
24
|
+
if (PALETTE[name] != null) return PALETTE[name];
|
|
25
|
+
let h = 0;
|
|
26
|
+
for (const ch of name) h = ((h * 31) + ch.charCodeAt(0)) >>> 0;
|
|
27
|
+
return FALLBACK[h % FALLBACK.length];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* No-op log sink for embedders (e.g. tests) that opt out of CLI summary chatter.
|
|
32
|
+
* Errors keep their own loud channels ([esbuild], [workerd]) and are never routed here.
|
|
33
|
+
*/
|
|
34
|
+
export function silentLog() {}
|
|
35
|
+
|
|
36
|
+
/** Source logger: `const log = sectionLog('typescript'); log('line or a block\nof lines')` */
|
|
37
|
+
export function sectionLog(name) {
|
|
38
|
+
const color = colorOf(name);
|
|
39
|
+
return (text) => {
|
|
40
|
+
const tty = Boolean(process.stdout.isTTY);
|
|
41
|
+
const paint = (s, bold) => (tty ? `\x1b[${bold ? '1;' : ''}${color}m${s}\x1b[0m` : s);
|
|
42
|
+
let out = '';
|
|
43
|
+
if (lastSection !== name) {
|
|
44
|
+
out += `${lastSection === null ? '' : '\n'}${paint(`[${name}]`, true)}\n`;
|
|
45
|
+
lastSection = name;
|
|
46
|
+
}
|
|
47
|
+
for (const line of String(text).replace(/\n+$/, '').split('\n')) {
|
|
48
|
+
// lines already styled (tables paint their own separators) are not repainted
|
|
49
|
+
out += line.includes('\x1b[') ? `${line}\n` : `${paint(line, false)}\n`;
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(out);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// table separator colors (deliberately different): vertical │ — gray, horizontal ─┼ — blue
|
|
56
|
+
const SEP_V_COLOR = 90;
|
|
57
|
+
const SEP_H_COLOR = 34;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Aligned table with no dependencies: formatTable(['module', 'size'], [['a', '1.2 KB']]).
|
|
61
|
+
* A cell may be multi-line ('component\nscript') — a logical row expands into several physical
|
|
62
|
+
* ones (missing cells pad with emptiness), horizontal separators go BETWEEN logical rows,
|
|
63
|
+
* vertical ones between columns on every line.
|
|
64
|
+
*/
|
|
65
|
+
export function formatTable(headers, rows) {
|
|
66
|
+
const tty = Boolean(process.stdout.isTTY);
|
|
67
|
+
const paint = (s, color) => (tty ? `\x1b[${color}m${s}\x1b[0m` : s);
|
|
68
|
+
const expand = (row) => {
|
|
69
|
+
const split = row.map((c) => String(c).split('\n'));
|
|
70
|
+
const height = Math.max(...split.map((c) => c.length));
|
|
71
|
+
return Array.from({ length: height }, (_, i) => split.map((c) => c[i] ?? ''));
|
|
72
|
+
};
|
|
73
|
+
const logical = [headers, ...rows].map(expand);
|
|
74
|
+
const physical = logical.flat();
|
|
75
|
+
const widths = headers.map((_, i) => Math.max(...physical.map((r) => r[i].length)));
|
|
76
|
+
const sepV = paint(' │ ', SEP_V_COLOR);
|
|
77
|
+
const line = (r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(widths[i]))).join(sepV).trimEnd();
|
|
78
|
+
const rule = paint(widths.map((w) => '─'.repeat(w)).join('─┼─'), SEP_H_COLOR);
|
|
79
|
+
const out = [];
|
|
80
|
+
logical.forEach((block, i) => {
|
|
81
|
+
if (i > 0) out.push(rule); // after the header and between logical rows
|
|
82
|
+
out.push(...block.map(line));
|
|
83
|
+
});
|
|
84
|
+
return out.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Report over the LOGICAL build artifacts (report from build()): a table per distribution —
|
|
89
|
+
* a row per module, facets/sizes stacked inside the row — plus a table of dependency artifacts.
|
|
90
|
+
*/
|
|
91
|
+
export function renderBuildReport(report) {
|
|
92
|
+
if (!report) return null;
|
|
93
|
+
const blocks = [];
|
|
94
|
+
for (const d of report.distributions) {
|
|
95
|
+
const total = d.modules.reduce((s, m) => s + m.facets.reduce((x, f) => x + f.bytes, 0), 0);
|
|
96
|
+
const rows = d.modules.map((m) => [
|
|
97
|
+
m.name,
|
|
98
|
+
m.facets.map((f) => f.facet).join('\n'),
|
|
99
|
+
m.facets.map((f) => formatBytes(f.bytes)).join('\n'),
|
|
100
|
+
m.facets.map((f) => f.info).join('\n'),
|
|
101
|
+
]);
|
|
102
|
+
blocks.push(`tile palette ${d.namespace}@${d.version} — ${d.modules.length} tile(s), total ${formatBytes(total)}\n`
|
|
103
|
+
+ formatTable(['tile', 'side', 'size', 'info'], rows));
|
|
104
|
+
}
|
|
105
|
+
if (report.dependencies.length) {
|
|
106
|
+
const total = report.dependencies.reduce((s, r) => s + r.bytes, 0);
|
|
107
|
+
const rows = [...report.dependencies]
|
|
108
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
109
|
+
.map((r) => [r.specifier, r.version, formatBytes(r.bytes)]);
|
|
110
|
+
blocks.push(`package tiles — ${report.dependencies.length}, total ${formatBytes(total)}\n`
|
|
111
|
+
+ formatTable(['package', 'version', 'size'], rows));
|
|
112
|
+
}
|
|
113
|
+
return blocks.length ? blocks.join('\n\n') : null;
|
|
114
|
+
}
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
// `mosetta login`: the CLI signs in to a platform installation the way `gh auth login` does — by a
|
|
7
|
+
// code the person approves in the browser (a device flow). The CLI asks the cabinet for a sign-in,
|
|
8
|
+
// shows the code and opens the cabinet's page for it; the person, signed in there, allows it; the
|
|
9
|
+
// CLI, polling with a secret only it holds, gets a token of the platform. The token is kept in the
|
|
10
|
+
// person's config folder (readable by them only), one per installation — `MOSETTA_TOKEN` (CI, an
|
|
11
|
+
// assistant) wins over it.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where an app is published: the `publish` entry of its mosetta.json, by target (`default`, `stage`)
|
|
15
|
+
* — `{ "cabinet": "https://mosetta.org", "api": "https://api.mosetta.org", "target": "platform" }`.
|
|
16
|
+
* Not a secret: it is committed with the app. An explicit `cabinet` (a flag) wins over it.
|
|
17
|
+
*/
|
|
18
|
+
export function publishTargetOf(root, name = 'default', { cabinet } = {}) {
|
|
19
|
+
let entry = null;
|
|
20
|
+
try {
|
|
21
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'mosetta.json'), 'utf8'));
|
|
22
|
+
entry = manifest?.publish?.[name] ?? null;
|
|
23
|
+
} catch { /* no manifest — the flags must say it */ }
|
|
24
|
+
const chosen = { ...(entry ?? {}), ...(cabinet ? { cabinet } : {}) };
|
|
25
|
+
if (!chosen.cabinet) {
|
|
26
|
+
throw new Error(`no installation to sign in to: ${path.join(root, 'mosetta.json')} has no publish.${name}.cabinet — or name one with --cabinet <origin>`);
|
|
27
|
+
}
|
|
28
|
+
return chosen;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** where the CLI keeps what is the person's: `$MOSETTA_CONFIG_DIR`, or `~/.config/mosetta` */
|
|
32
|
+
export function configDir(env = process.env) {
|
|
33
|
+
if (env.MOSETTA_CONFIG_DIR) return env.MOSETTA_CONFIG_DIR;
|
|
34
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mosetta');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const credentialsFile = (dir) => path.join(dir, 'credentials.json');
|
|
38
|
+
|
|
39
|
+
function readCredentials(dir) {
|
|
40
|
+
try {
|
|
41
|
+
const value = JSON.parse(fs.readFileSync(credentialsFile(dir), 'utf8'));
|
|
42
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** the credentials file rewritten whole: the folder and the file the person's only */
|
|
49
|
+
function writeCredentials(dir, all) {
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
|
+
const file = credentialsFile(dir);
|
|
52
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
53
|
+
fs.writeFileSync(temporary, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
54
|
+
fs.renameSync(temporary, file);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** an installation by its cabinet's origin — `https://mosetta.org` */
|
|
58
|
+
export const installationOf = (cabinet) => new URL(cabinet).origin;
|
|
59
|
+
|
|
60
|
+
/** the token this CLI acts with on an installation: `MOSETTA_TOKEN`, else the one `login` kept, else null */
|
|
61
|
+
export function tokenFor(cabinet, { env = process.env, dir = configDir(env), now = new Date() } = {}) {
|
|
62
|
+
if (env.MOSETTA_TOKEN) return { token: env.MOSETTA_TOKEN, from: 'MOSETTA_TOKEN' };
|
|
63
|
+
const kept = readCredentials(dir)[installationOf(cabinet)];
|
|
64
|
+
if (!kept?.token) return null;
|
|
65
|
+
if (kept.expiresAt && Date.parse(kept.expiresAt) <= now.getTime()) return null;
|
|
66
|
+
return { token: kept.token, from: credentialsFile(dir), login: kept.login, expiresAt: kept.expiresAt };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function forget(cabinet, { dir = configDir() } = {}) {
|
|
70
|
+
const all = readCredentials(dir);
|
|
71
|
+
const key = installationOf(cabinet);
|
|
72
|
+
if (!(key in all)) return false;
|
|
73
|
+
delete all[key];
|
|
74
|
+
writeCredentials(dir, all);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** the person's browser on the page — best effort: the address is printed either way */
|
|
79
|
+
export function openInBrowser(url) {
|
|
80
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
81
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
82
|
+
: ['xdg-open', [url]];
|
|
83
|
+
try {
|
|
84
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
85
|
+
child.on('error', () => undefined);
|
|
86
|
+
child.unref();
|
|
87
|
+
} catch { /* no browser here — the printed address is enough */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sign in to the installation whose cabinet is `cabinet`; keeps the token and answers who signed in.
|
|
94
|
+
* @param fetchImpl the network (tests answer from the cabinet's own handler)
|
|
95
|
+
*/
|
|
96
|
+
export async function login(cabinet, {
|
|
97
|
+
label = `CLI on ${os.hostname()}`,
|
|
98
|
+
fetchImpl = fetch,
|
|
99
|
+
sleep = pause,
|
|
100
|
+
open = openInBrowser,
|
|
101
|
+
log = (line) => console.log(line),
|
|
102
|
+
dir = configDir(),
|
|
103
|
+
now = () => new Date(),
|
|
104
|
+
} = {}) {
|
|
105
|
+
const origin = installationOf(cabinet);
|
|
106
|
+
const asked = await fetchImpl(`${origin}/__mosetta__/sign-in`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ label }),
|
|
110
|
+
});
|
|
111
|
+
if (!asked.ok) throw new Error(`login: ${origin} did not start a sign-in (${asked.status} ${(await asked.text()).slice(0, 200)})`);
|
|
112
|
+
const { userCode, pollSecret, verifyUrl, interval, expiresAt } = await asked.json();
|
|
113
|
+
log(`Open ${verifyUrl} and allow the sign-in — the page shows the code ${userCode}.`);
|
|
114
|
+
open(verifyUrl);
|
|
115
|
+
const every = Math.max(1, Number(interval) || 3) * 1000;
|
|
116
|
+
while (now().getTime() < Date.parse(expiresAt)) {
|
|
117
|
+
await sleep(every);
|
|
118
|
+
const polled = await fetchImpl(`${origin}/__mosetta__/sign-in/poll`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'content-type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ pollSecret }),
|
|
122
|
+
});
|
|
123
|
+
if (polled.status === 202) {
|
|
124
|
+
await polled.arrayBuffer();
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!polled.ok) throw new Error(`login: the sign-in was not allowed in time, or was used already — run mosetta login again (${polled.status})`);
|
|
128
|
+
const got = await polled.json();
|
|
129
|
+
const all = readCredentials(dir);
|
|
130
|
+
all[origin] = { token: got.token, via: got.user.via, login: got.user.login, label: got.label, expiresAt: got.expiresAt };
|
|
131
|
+
writeCredentials(dir, all);
|
|
132
|
+
log(`Signed in to ${origin} as ${got.user.login}; the token lives until ${got.expiresAt.slice(0, 10)} (revoke it in the cabinet's access tokens).`);
|
|
133
|
+
return { login: got.user.login, expiresAt: got.expiresAt };
|
|
134
|
+
}
|
|
135
|
+
throw new Error('login: the sign-in was not allowed in time — run mosetta login again');
|
|
136
|
+
}
|
package/src/push.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { manifestOf } from '@mosetta/build/release';
|
|
4
|
+
import { writeTar } from '@mosetta/build/archive';
|
|
5
|
+
import { builtDistribution } from './deploy.mjs';
|
|
6
|
+
import { publishTargetOf, tokenFor } from './login.mjs';
|
|
7
|
+
import { sectionLog } from './log.mjs';
|
|
8
|
+
|
|
9
|
+
// `mosetta push`: the built distribution released THROUGH a platform — no store key on this machine.
|
|
10
|
+
// The CLI describes the build (both catalogs, every file with its SHA-256, the commit it comes
|
|
11
|
+
// from), asks the platform's API for a plan, and sends the files the plan asks for; the platform
|
|
12
|
+
// checks every byte and runs the release rules on its side, writing with keys only it holds. Where
|
|
13
|
+
// to push is the app's `publish` target in mosetta.json; who pushes is the token `mosetta login`
|
|
14
|
+
// kept (or MOSETTA_TOKEN).
|
|
15
|
+
|
|
16
|
+
/** where the build comes from, as git says — the platform keeps it with the release */
|
|
17
|
+
export function provenanceOf(root) {
|
|
18
|
+
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
19
|
+
try {
|
|
20
|
+
return { commit: git('rev-parse', 'HEAD'), branch: git('rev-parse', '--abbrev-ref', 'HEAD'), dirty: git('status', '--porcelain') !== '' };
|
|
21
|
+
} catch {
|
|
22
|
+
// not a repository: the platform hears of no commit, and a target that takes commits only refuses
|
|
23
|
+
return { commit: null, branch: null, dirty: null };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function answerOf(res) {
|
|
28
|
+
const text = await res.text();
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
return { error: { message: text.slice(0, 300) } };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** a refusal or failure of the API, said as it said it */
|
|
37
|
+
function failureOf(res, body, doing) {
|
|
38
|
+
if (body?.refused) return new Error(body.refused);
|
|
39
|
+
const message = body?.error?.message ?? body?.error?.code ?? `${res.status}`;
|
|
40
|
+
if (res.status === 401) return new Error(`push: the platform did not take the token (${message}) — mosetta login`);
|
|
41
|
+
return new Error(`push: ${doing} failed — ${message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param root app root (holds dist/ after `mosetta build`, and mosetta.json with its publish targets)
|
|
46
|
+
* @param target the publish target's name in mosetta.json (`default`, `stage`)
|
|
47
|
+
*/
|
|
48
|
+
export async function push(root, {
|
|
49
|
+
target = 'default',
|
|
50
|
+
dryRun = false,
|
|
51
|
+
fetchImpl = fetch,
|
|
52
|
+
env = process.env,
|
|
53
|
+
dir,
|
|
54
|
+
provenance = provenanceOf(root),
|
|
55
|
+
log = sectionLog('push'),
|
|
56
|
+
} = {}) {
|
|
57
|
+
const where = publishTargetOf(root, target);
|
|
58
|
+
if (!where.api || !where.target) throw new Error(`push: publish.${target} in ${path.join(root, 'mosetta.json')} names no "api" and "target"`);
|
|
59
|
+
const held = tokenFor(where.cabinet, { env, ...(dir ? { dir } : {}) });
|
|
60
|
+
if (!held) throw new Error(`push: not signed in to ${where.cabinet} — mosetta login${target === 'default' ? '' : ` --target ${target}`}`);
|
|
61
|
+
const build = builtDistribution(path.join(root, 'dist'));
|
|
62
|
+
const manifest = await manifestOf(build, provenance);
|
|
63
|
+
// a palette's target has a slash of its own (`palettes/<brand>`): each part encoded, the slash kept
|
|
64
|
+
const base = `${new URL(where.api).origin}/v1/code/${where.target.split('/').map(encodeURIComponent).join('/')}`;
|
|
65
|
+
const authorization = `Bearer ${held.token}`;
|
|
66
|
+
|
|
67
|
+
const planned = await fetchImpl(`${base}/plan`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { authorization, 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(manifest),
|
|
71
|
+
});
|
|
72
|
+
const plan = await answerOf(planned);
|
|
73
|
+
if (!planned.ok) throw failureOf(planned, plan, 'planning');
|
|
74
|
+
const asked = [...plan.need.public.map((key) => ['public', key]), ...plan.need.private.map((key) => ['private', key])];
|
|
75
|
+
log(`release into "${where.target}" at ${where.api}: new ${plan.fresh.join(', ') || 'nothing'}; ${plan.same} tile(s) as deployed, `
|
|
76
|
+
+ `${plan.rebuilt} rebuilt only for a new framework, ${plan.kept} published package(s) kept; ${asked.length} file(s) to send`);
|
|
77
|
+
if (plan.conflicts.length) {
|
|
78
|
+
log(`${plan.conflicts.length} tile(s) differ from their released copies — refused unless only the framework's versions changed: `
|
|
79
|
+
+ `${[...new Set(plan.conflicts.map((c) => c.release))].join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
if (dryRun) return { dryRun: true, ...plan };
|
|
82
|
+
|
|
83
|
+
const entries = [{ name: 'release.json', data: new TextEncoder().encode(JSON.stringify(manifest)) }];
|
|
84
|
+
for (const [side, key] of asked) {
|
|
85
|
+
const data = await build.read(side, key);
|
|
86
|
+
if (data === null) throw new Error(`push: ${side} ${key} was asked for but is not in the build`);
|
|
87
|
+
entries.push({ name: `${side}/${key}`, data });
|
|
88
|
+
}
|
|
89
|
+
const pushed = await fetchImpl(`${base}/push`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { authorization, 'content-type': 'application/x-tar' },
|
|
92
|
+
body: writeTar(entries),
|
|
93
|
+
});
|
|
94
|
+
const answer = await answerOf(pushed);
|
|
95
|
+
if (!pushed.ok || !answer.ok) throw failureOf(pushed, answer, 'pushing');
|
|
96
|
+
const { release } = answer;
|
|
97
|
+
log(`released ${release.fresh.join(', ') || 'nothing new'} into "${where.target}" (${release.uploaded.public} public, ${release.uploaded.private} private file(s)); trace ${release.id}`);
|
|
98
|
+
return release;
|
|
99
|
+
}
|
package/src/scanner.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { FRAMEWORK_RESOLUTIONS } from '@mosetta/build/packagesResolver';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The node side of the scanner: ONLY framework resolutions — files from the CLI's own
|
|
6
|
+
* installation (systemjs etc. — its dependencies) that the build mixes in by itself.
|
|
7
|
+
*
|
|
8
|
+
* The package-catalog scanner itself is portable and lives in `@mosetta/build/scanner`: it
|
|
9
|
+
* takes the file port and uses only sources and pnpm-lock.yaml from it — exactly what the
|
|
10
|
+
* scanner isolate will have. Only `createRequire` remains here, because resolving the tool's
|
|
11
|
+
* installation is an environment capability; in the isolate a pre-seeded map in the plan
|
|
12
|
+
* replaces it.
|
|
13
|
+
*/
|
|
14
|
+
const cliRequire = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
/** File for a framework specifier from the CLI installation (for frameworkDir in harnesses). */
|
|
17
|
+
export function frameworkFileOf(specifier) {
|
|
18
|
+
return cliRequire.resolve(specifier);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `catalog.framework`: ready answers for the FRAMEWORK_RESOLUTIONS list. */
|
|
22
|
+
export function frameworkResolutions() {
|
|
23
|
+
const framework = {};
|
|
24
|
+
for (const spec of FRAMEWORK_RESOLUTIONS) framework[spec] = cliRequire.resolve(spec);
|
|
25
|
+
return framework;
|
|
26
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { createWasmEngine } from './wasmEngine.mjs';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
// @mosetta/server holds the render runtime (TS, OOP). The CLI consumes it as SOURCE via esbuild (same
|
|
11
|
+
// convention as the @mosetta/* libraries) — bundled for workerd (the worker) or imported in node
|
|
12
|
+
// (SSG renderer / tests). No compiled dist is ever emitted: `.ts` is the canonical form.
|
|
13
|
+
//
|
|
14
|
+
// Resolution prefers node module resolution via the package's `exports` map (`./worker`, `./render`), so
|
|
15
|
+
// it keeps working once @mosetta/server is a real dependency / published to npm (installed under
|
|
16
|
+
// node_modules by package NAME, not sibling-dir layout). It falls back to the monorepo sibling path for
|
|
17
|
+
// now, because the package isn't yet symlinked into node_modules (nothing declares it as a dependency).
|
|
18
|
+
const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const SIBLING_SERVER_DIR = path.resolve(CLI_ROOT, '..', 'server');
|
|
20
|
+
|
|
21
|
+
function resolveServerEntry(subpath, siblingFile) {
|
|
22
|
+
try {
|
|
23
|
+
return require.resolve(`@mosetta/server/${subpath}`);
|
|
24
|
+
} catch {
|
|
25
|
+
return path.join(SIBLING_SERVER_DIR, 'src', siblingFile);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SERVER_WORKER_ENTRY = resolveServerEntry('worker', 'worker.ts');
|
|
30
|
+
// dev entry: render + static serving from a Durable Object on a single port
|
|
31
|
+
export const SERVER_DEV_WORKER_ENTRY = resolveServerEntry('devWorker', 'devWorker.ts');
|
|
32
|
+
export const SERVER_RENDER_ENTRY = resolveServerEntry('render', 'renderNode.ts');
|
|
33
|
+
|
|
34
|
+
// Bundle a server entry to a single ESM string. platform 'neutral' = Web API (workerd); 'node' = in-process.
|
|
35
|
+
// In-process (node) execution of a server entry — tests, the static render: workerd's own
|
|
36
|
+
// `cloudflare:workers` does not exist there. The host entrypoint only needs the base class to
|
|
37
|
+
// exist; node has no ctx.exports, so the runner falls back to in-process capabilities.
|
|
38
|
+
const WORKERS_SHIM = {
|
|
39
|
+
name: 'mosetta-cloudflare-workers-shim',
|
|
40
|
+
setup(build) {
|
|
41
|
+
build.onResolve({ filter: /^cloudflare:workers$/ }, () => ({ path: 'cloudflare:workers', namespace: 'mosetta-cf-shim' }));
|
|
42
|
+
build.onLoad({ filter: /.*/, namespace: 'mosetta-cf-shim' }, () => ({
|
|
43
|
+
contents: 'export class WorkerEntrypoint { constructor(ctx, env) { this.ctx = ctx; this.env = env; } }',
|
|
44
|
+
loader: 'js',
|
|
45
|
+
}));
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export async function bundleServer(entry, { platform = 'neutral', mode = 'development' } = {}) {
|
|
50
|
+
// with the same wasm engine as the distributive: the CLI has no native binaries
|
|
51
|
+
const { bundle } = await createWasmEngine();
|
|
52
|
+
const result = await bundle({
|
|
53
|
+
entryPoints: [entry],
|
|
54
|
+
bundle: true,
|
|
55
|
+
format: 'esm',
|
|
56
|
+
platform,
|
|
57
|
+
target: 'es2022',
|
|
58
|
+
write: false,
|
|
59
|
+
minify: mode === 'production',
|
|
60
|
+
// the Workers runtime's own modules (WorkerEntrypoint for the store capability) — workerd
|
|
61
|
+
// provides them at run time; in node a shim stands in
|
|
62
|
+
...(platform === 'node' ? { plugins: [WORKERS_SHIM] } : { external: ['cloudflare:*'] }),
|
|
63
|
+
});
|
|
64
|
+
return result.outputFiles[0].text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Bundle + import a server entry for in-process (node) use; returns { module, cleanup }.
|
|
68
|
+
export async function importServer(entry, { platform = 'node' } = {}) {
|
|
69
|
+
const code = await bundleServer(entry, { platform });
|
|
70
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mosetta-server-'));
|
|
71
|
+
const file = path.join(dir, 'entry.mjs');
|
|
72
|
+
fs.writeFileSync(file, code);
|
|
73
|
+
const module = await import(pathToFileURL(file).href);
|
|
74
|
+
return { module, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
|
75
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { build } from '@mosetta/build';
|
|
4
|
+
import { PackagesResolver } from '@mosetta/build/packagesResolver';
|
|
5
|
+
import { loadBuildConfig } from './buildConfigLoader.mjs';
|
|
6
|
+
import { scanCatalog } from '@mosetta/build/scanner';
|
|
7
|
+
import { frameworkResolutions } from './scanner.mjs';
|
|
8
|
+
import { importServer, SERVER_RENDER_ENTRY } from './serverPackage.mjs';
|
|
9
|
+
import { sectionLog, silentLog, renderBuildReport } from './log.mjs';
|
|
10
|
+
import { evaluateModule } from './evaluate.mjs';
|
|
11
|
+
|
|
12
|
+
const isAbstractRoute = (pattern) => typeof pattern === 'string' && pattern.includes('{');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Static publish / SSG. Produces a deployable static distributive in <root>/dist, split
|
|
16
|
+
* into two top-level folders so nothing crosses the trust boundary on deploy:
|
|
17
|
+
* dist/public/ — client-facing static (modules + co-located loaders + dependencies + non-devOnly
|
|
18
|
+
* prefabs + per-concrete-route <name>.html + pages.json). Its CONTENTS deploy to the CDN.
|
|
19
|
+
* dist/private/ — server-only artifacts (server-action monoliths). Deploy to the private store (S3), not
|
|
20
|
+
* the public CDN. Empty for a pure SPA (server modules are devOnly / excluded here).
|
|
21
|
+
* devOnly assets and the per-file pages/ collection are NOT shipped. Abstract (param) routes would go into
|
|
22
|
+
* a catch-all index.html — deferred until the client router + patterns land.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The ids a tile host descriptor names — read from a file or an https address — as a build takes
|
|
26
|
+
* them for `released`: a tile palette built for a platform skips the palettes and published
|
|
27
|
+
* packages the platform already holds.
|
|
28
|
+
*/
|
|
29
|
+
export async function releasedIdsOf(where) {
|
|
30
|
+
const text = /^https?:\/\//.test(where)
|
|
31
|
+
? await (async () => {
|
|
32
|
+
const res = await fetch(where);
|
|
33
|
+
if (!res.ok) throw new Error(`build: --released ${where} answered ${res.status}`);
|
|
34
|
+
return res.text();
|
|
35
|
+
})()
|
|
36
|
+
: fs.readFileSync(where, 'utf8');
|
|
37
|
+
const entries = JSON.parse(text);
|
|
38
|
+
if (!Array.isArray(entries)) throw new Error(`build: --released ${where} is not a tile host descriptor (an array)`);
|
|
39
|
+
return new Set(entries.map((entry) => entry?.id).filter((id) => typeof id === 'string'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `released` — the ids already where the build goes (releasedIdsOf): not built again */
|
|
43
|
+
export async function staticBuild(root, { outDir, log = sectionLog('cli'), quiet = false, fileSystem, engine, frameworkDir, released = null }) {
|
|
44
|
+
// quiet (tests): summaries stay silent, build errors are still thrown as exceptions
|
|
45
|
+
const out = quiet ? silentLog : log;
|
|
46
|
+
const dist = outDir || path.join(root, 'dist');
|
|
47
|
+
const buildConfig = await loadBuildConfig(root);
|
|
48
|
+
// the package catalog is fresh per build iteration; the prod closure sees dependencies only:
|
|
49
|
+
// importing a devDep fails with a "move it to dependencies" explanation
|
|
50
|
+
const resolver = new PackagesResolver({
|
|
51
|
+
fileSystem,
|
|
52
|
+
catalog: { ...scanCatalog({ fileSystem, root, frameworkDir, includeDev: false }), framework: frameworkResolutions() },
|
|
53
|
+
});
|
|
54
|
+
const state = await build(root, { mode: 'production', log: out, fileSystem, resolver, engine, frameworkDir, buildConfig, evaluate: evaluateModule, released });
|
|
55
|
+
const report = renderBuildReport(state.report);
|
|
56
|
+
if (report && !quiet) sectionLog('assets')(report);
|
|
57
|
+
|
|
58
|
+
fs.rmSync(dist, { recursive: true, force: true });
|
|
59
|
+
const write = (rel, content) => {
|
|
60
|
+
const p = path.join(dist, rel);
|
|
61
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
62
|
+
fs.writeFileSync(p, content);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// 1) copy build artifacts as-is (FS keys already carry the public/ | private/ split), EXCEPT the
|
|
66
|
+
// per-file public/pages/ collection and devOnly prefabs. system.js is a public/ artifact → shipped here.
|
|
67
|
+
const devOnlyPrefabFiles = new Set(
|
|
68
|
+
state.prefabs.filter((p) => p.json && p.json.devOnly).map((p) => `public/prefabs/${p.namespace}/${p.name}.json`),
|
|
69
|
+
);
|
|
70
|
+
for (const [rel, content] of state.fs.files) {
|
|
71
|
+
if (rel.startsWith('public/pages/')) continue; // pages collection is not shipped (HTML + pages.json instead)
|
|
72
|
+
if (devOnlyPrefabFiles.has(rel)) continue; // devOnly prefabs excluded from the distributive
|
|
73
|
+
write(rel, content);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2) pages → HTML (concrete routes, flat in public/) + pages.json (all non-devOnly pages, for SPA router).
|
|
77
|
+
// Render via @mosetta/server (the SAME renderer the dev worker uses), with relative refs.
|
|
78
|
+
const { module: server, cleanup } = await importServer(SERVER_RENDER_ENTRY);
|
|
79
|
+
const shipped = state.pages.filter((p) => !(p.json && p.json.devOnly));
|
|
80
|
+
// the unified entity catalog: boot tags + preload + styles + the slice into the envelope
|
|
81
|
+
const catalog = JSON.parse(state.fs.get('public/modules.json') ?? '[]');
|
|
82
|
+
let html = 0;
|
|
83
|
+
let skipped = 0;
|
|
84
|
+
try {
|
|
85
|
+
for (const pg of shipped) {
|
|
86
|
+
if (isAbstractRoute(pg.json.pattern)) { skipped++; continue; } // → index.html later (deferred)
|
|
87
|
+
write(`public/${pg.name}.html`, server.renderPage(pg.json, { providerOrigin: '', catalog }));
|
|
88
|
+
html++;
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
cleanup();
|
|
92
|
+
}
|
|
93
|
+
write('public/pages.json', JSON.stringify(shipped.map((p) => p.json)));
|
|
94
|
+
|
|
95
|
+
out(`mosetta build → ${path.relative(root, dist) || dist}: ${html} page HTML, ${shipped.length} page(s) in pages.json`
|
|
96
|
+
+ (skipped ? `, ${skipped} abstract route(s) skipped (need index.html — deferred)` : '')
|
|
97
|
+
+ `, ${state.pages.length - shipped.length} devOnly page(s) excluded`);
|
|
98
|
+
|
|
99
|
+
return { dist, htmlCount: html, pages: shipped.length };
|
|
100
|
+
}
|