@vmz/vmz 0.0.3 → 0.0.4

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/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # VMZ CLI
1
+ # `@vmz/vmz` (bin: `vmz`)
2
+
3
+ CLI / N-API toolchain host. Runtime mental entry is **`@vmz/core`**; optional official UI is **`@vmz/ui`**; plugins are
4
+ **`@vmz/plugin-*`**. Shelved umbrella name: `@vmz/vmz`.
2
5
 
3
6
  When a project reaches the point where it has a browser surface, SSR, server work, documents, tests, and several
4
7
  deployment concerns, the usual workflow becomes a chain of tools that each see a different version of the application. A
@@ -22,9 +25,8 @@ With VMZ, a normal workflow is expected to answer all of these from the same sou
22
25
  - **Test:** what did a user interaction actually cause?
23
26
  - **Explain:** why did the compiler make that decision?
24
27
 
25
- Use it when you are adopting VMZ as the application model. It is intentionally not a compatibility compiler for
26
- arbitrary Vue components, React hooks, or legacy VDOM applications; those ecosystems are better served by their native
27
- toolchains.
28
+ Use it when you are adopting VMZ as the application model. The CLI targets VMZ-native source and semantics; Vue, React,
29
+ and legacy VDOM ecosystems keep their own toolchains.
28
30
 
29
31
  ## One tool, several views of the same program
30
32
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * A3-cdn: vendor-neutral CDN policy (cache / redirect / error) + local static host.
3
+ * Provider adapters only project the same contract — they must not change RouteId/canonical/CSP.
4
+ */
5
+ export declare const CDN_POLICY_MANIFEST_SCHEMA = "vmz.cdn.policy_manifest.v0";
6
+ export declare const CDN_ADAPTER_PROJECTION_SCHEMA = "vmz.cdn.adapter_projection.v0";
7
+ /** HTML: revalidate. Hashed/static assets: long immutable. */
8
+ export declare const CACHE_HTML = "public, max-age=0, must-revalidate";
9
+ export declare const CACHE_ASSET_IMMUTABLE = "public, max-age=31536000, immutable";
10
+ export declare const CACHE_META = "public, max-age=3600";
11
+ /**
12
+ * Build CDNPolicyManifest from StaticDeliveryManifest (+ optional redirects).
13
+ * @param {Record<string, any>} staticManifest
14
+ * @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
15
+ */
16
+ export declare function buildCdnPolicyManifest(staticManifest: any, opts?: {}): {
17
+ schema: string;
18
+ applicationId: any;
19
+ deliveryProfile: string;
20
+ origin: string;
21
+ spaFallback: boolean;
22
+ staticManifestDigest: any;
23
+ redirects: any[];
24
+ headers: ({
25
+ match: string;
26
+ headers: {
27
+ 'cache-control': string;
28
+ 'x-robots-tag'?: undefined;
29
+ };
30
+ } | {
31
+ match: string;
32
+ headers: {
33
+ 'cache-control': string;
34
+ 'x-robots-tag': string;
35
+ };
36
+ })[];
37
+ errorDocuments: any;
38
+ routes: any;
39
+ };
40
+ /**
41
+ * Write CDN policy + adapter projections under dist/_vmz.
42
+ * @param {string} distDir
43
+ * @param {Record<string, any>} staticManifest
44
+ * @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
45
+ */
46
+ export declare function emitCdnPolicy(distDir: any, staticManifest: any, opts?: {}): {
47
+ policy: {
48
+ schema: string;
49
+ applicationId: any;
50
+ deliveryProfile: string;
51
+ origin: string;
52
+ spaFallback: boolean;
53
+ staticManifestDigest: any;
54
+ redirects: any[];
55
+ headers: ({
56
+ match: string;
57
+ headers: {
58
+ 'cache-control': string;
59
+ 'x-robots-tag'?: undefined;
60
+ };
61
+ } | {
62
+ match: string;
63
+ headers: {
64
+ 'cache-control': string;
65
+ 'x-robots-tag': string;
66
+ };
67
+ })[];
68
+ errorDocuments: any;
69
+ routes: any;
70
+ };
71
+ adapters: {
72
+ 'local-static': {
73
+ schema: string;
74
+ adapterId: string;
75
+ policyDigest: any;
76
+ host: string;
77
+ spaFallback: boolean;
78
+ redirects: any;
79
+ headers: any;
80
+ errorDocuments: any;
81
+ files: {
82
+ _headers?: undefined;
83
+ _redirects?: undefined;
84
+ };
85
+ } | {
86
+ schema: string;
87
+ adapterId: string;
88
+ policyDigest: any;
89
+ host: string;
90
+ spaFallback: boolean;
91
+ redirects: any;
92
+ headers: any;
93
+ errorDocuments: any;
94
+ files: {
95
+ _headers: string;
96
+ _redirects: string;
97
+ };
98
+ };
99
+ netlify: {
100
+ schema: string;
101
+ adapterId: string;
102
+ policyDigest: any;
103
+ host: string;
104
+ spaFallback: boolean;
105
+ redirects: any;
106
+ headers: any;
107
+ errorDocuments: any;
108
+ files: {
109
+ _headers?: undefined;
110
+ _redirects?: undefined;
111
+ };
112
+ } | {
113
+ schema: string;
114
+ adapterId: string;
115
+ policyDigest: any;
116
+ host: string;
117
+ spaFallback: boolean;
118
+ redirects: any;
119
+ headers: any;
120
+ errorDocuments: any;
121
+ files: {
122
+ _headers: string;
123
+ _redirects: string;
124
+ };
125
+ };
126
+ };
127
+ };
128
+ /**
129
+ * Project vendor-neutral policy to an adapter. Must preserve redirect targets and forbid SPA fallback.
130
+ * @param {Record<string, any>} policy
131
+ * @param {'local-static' | 'netlify'} adapterId
132
+ */
133
+ export declare function projectCdnAdapter(policy: any, adapterId: any): {
134
+ schema: string;
135
+ adapterId: string;
136
+ policyDigest: any;
137
+ host: string;
138
+ spaFallback: boolean;
139
+ redirects: any;
140
+ headers: any;
141
+ errorDocuments: any;
142
+ files: {
143
+ _headers?: undefined;
144
+ _redirects?: undefined;
145
+ };
146
+ } | {
147
+ schema: string;
148
+ adapterId: string;
149
+ policyDigest: any;
150
+ host: string;
151
+ spaFallback: boolean;
152
+ redirects: any;
153
+ headers: any;
154
+ errorDocuments: any;
155
+ files: {
156
+ _headers: string;
157
+ _redirects: string;
158
+ };
159
+ };
160
+ /**
161
+ * Local static host that applies CDNPolicyManifest (redirects, cache headers, 404 doc).
162
+ * @param {string} distDir
163
+ * @param {Record<string, any>} policy
164
+ * @param {{ host?: string, port?: number }} [opts]
165
+ * @returns {Promise<{ host: string, port: number, baseUrl: string, close: () => Promise<void> }>}
166
+ */
167
+ export declare function listenLocalStaticHost(distDir: any, policy: any, opts?: {}): Promise<unknown>;
168
+ /**
169
+ * @param {string} distDir
170
+ * @param {Record<string, any>} policy
171
+ */
172
+ export declare function createLocalStaticHandler(distDir: any, policy: any): (req: any, res: any) => void;
173
+ /**
174
+ * Minimal glob matcher for CDN header rules (html / js|css|mjs / exact / prefix).
175
+ * @param {string} pattern
176
+ * @param {string} pathname
177
+ */
178
+ export declare function matchGlob(pattern: any, pathname: any): any;
@@ -0,0 +1,344 @@
1
+ /**
2
+ * A3-cdn: vendor-neutral CDN policy (cache / redirect / error) + local static host.
3
+ * Provider adapters only project the same contract — they must not change RouteId/canonical/CSP.
4
+ */
5
+ // @ts-nocheck
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import http from 'node:http';
9
+ import path from 'node:path';
10
+ export const CDN_POLICY_MANIFEST_SCHEMA = 'vmz.cdn.policy_manifest.v0';
11
+ export const CDN_ADAPTER_PROJECTION_SCHEMA = 'vmz.cdn.adapter_projection.v0';
12
+ /** HTML: revalidate. Hashed/static assets: long immutable. */
13
+ export const CACHE_HTML = 'public, max-age=0, must-revalidate';
14
+ export const CACHE_ASSET_IMMUTABLE = 'public, max-age=31536000, immutable';
15
+ export const CACHE_META = 'public, max-age=3600';
16
+ /**
17
+ * Build CDNPolicyManifest from StaticDeliveryManifest (+ optional redirects).
18
+ * @param {Record<string, any>} staticManifest
19
+ * @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
20
+ */
21
+ export function buildCdnPolicyManifest(staticManifest, opts = {}) {
22
+ const origin = String(staticManifest.origin || '');
23
+ const redirects = [{ from: '/home', to: '/', status: 301, reason: 'canonical-alias' }, ...(opts.redirects || [])];
24
+ const headers = [
25
+ { match: '**/*.html', headers: { 'cache-control': CACHE_HTML } },
26
+ {
27
+ match: '**/404.html',
28
+ headers: { 'cache-control': CACHE_HTML, 'x-robots-tag': 'noindex, nofollow' },
29
+ },
30
+ // Content-addressed immutable objects (A3 assets/<hash>).
31
+ { match: '**/assets/**', headers: { 'cache-control': CACHE_ASSET_IMMUTABLE } },
32
+ { match: '**/*.{js,css,mjs}', headers: { 'cache-control': CACHE_ASSET_IMMUTABLE } },
33
+ { match: '**/sitemap.xml', headers: { 'cache-control': CACHE_META } },
34
+ { match: '**/robots.txt', headers: { 'cache-control': CACHE_META } },
35
+ ];
36
+ const errorDocuments = Array.isArray(staticManifest.errorDocuments) ? staticManifest.errorDocuments : [{ status: 404, path: '404.html' }];
37
+ const body = {
38
+ schema: CDN_POLICY_MANIFEST_SCHEMA,
39
+ applicationId: staticManifest.applicationId || null,
40
+ deliveryProfile: 'web-static',
41
+ origin,
42
+ spaFallback: false,
43
+ staticManifestDigest: staticManifest.manifestDigest || null,
44
+ redirects,
45
+ headers,
46
+ errorDocuments,
47
+ routes: (staticManifest.routes || []).map((r) => ({
48
+ routeId: r.routeId,
49
+ path: r.path,
50
+ htmlPath: r.htmlPath,
51
+ canonical: r.seo?.canonical || null,
52
+ })),
53
+ };
54
+ body.policyDigest = sha256Hex(canonicalJson(body));
55
+ return body;
56
+ }
57
+ /**
58
+ * Write CDN policy + adapter projections under dist/_vmz.
59
+ * @param {string} distDir
60
+ * @param {Record<string, any>} staticManifest
61
+ * @param {{ redirects?: Array<{ from: string, to: string, status?: number, reason?: string }> }} [opts]
62
+ */
63
+ export function emitCdnPolicy(distDir, staticManifest, opts = {}) {
64
+ const policy = buildCdnPolicyManifest(staticManifest, opts);
65
+ const vmzDir = path.join(distDir, '_vmz');
66
+ fs.mkdirSync(vmzDir, { recursive: true });
67
+ fs.writeFileSync(path.join(vmzDir, 'cdn-policy-manifest.json'), `${JSON.stringify(policy, null, 2)}\n`, 'utf8');
68
+ const local = projectCdnAdapter(policy, 'local-static');
69
+ const netlify = projectCdnAdapter(policy, 'netlify');
70
+ const adaptersDir = path.join(vmzDir, 'adapters');
71
+ fs.mkdirSync(path.join(adaptersDir, 'local-static'), { recursive: true });
72
+ fs.mkdirSync(path.join(adaptersDir, 'netlify'), { recursive: true });
73
+ fs.writeFileSync(path.join(adaptersDir, 'local-static', 'projection.json'), `${JSON.stringify(local, null, 2)}\n`, 'utf8');
74
+ fs.writeFileSync(path.join(adaptersDir, 'netlify', 'projection.json'), `${JSON.stringify(netlify, null, 2)}\n`, 'utf8');
75
+ fs.writeFileSync(path.join(adaptersDir, 'netlify', '_headers'), String(netlify.files['_headers'] || ''), 'utf8');
76
+ fs.writeFileSync(path.join(adaptersDir, 'netlify', '_redirects'), String(netlify.files['_redirects'] || ''), 'utf8');
77
+ return { policy, adapters: { 'local-static': local, netlify } };
78
+ }
79
+ /**
80
+ * Project vendor-neutral policy to an adapter. Must preserve redirect targets and forbid SPA fallback.
81
+ * @param {Record<string, any>} policy
82
+ * @param {'local-static' | 'netlify'} adapterId
83
+ */
84
+ export function projectCdnAdapter(policy, adapterId) {
85
+ if (policy.spaFallback) {
86
+ throw new Error('projectCdnAdapter: spaFallback=true is forbidden');
87
+ }
88
+ if (adapterId === 'local-static') {
89
+ return {
90
+ schema: CDN_ADAPTER_PROJECTION_SCHEMA,
91
+ adapterId: 'local-static',
92
+ policyDigest: policy.policyDigest,
93
+ host: 'vmz-local-static',
94
+ spaFallback: false,
95
+ redirects: policy.redirects,
96
+ headers: policy.headers,
97
+ errorDocuments: policy.errorDocuments,
98
+ files: {},
99
+ };
100
+ }
101
+ if (adapterId === 'netlify') {
102
+ const headerLines = [];
103
+ for (const rule of policy.headers || []) {
104
+ const glob = netlifyGlob(rule.match);
105
+ headerLines.push(glob);
106
+ for (const [k, v] of Object.entries(rule.headers || {})) {
107
+ headerLines.push(` ${headerCase(k)}: ${v}`);
108
+ }
109
+ }
110
+ const redirectLines = [];
111
+ for (const r of policy.redirects || []) {
112
+ redirectLines.push(`${r.from} ${r.to} ${Number(r.status) || 301}`);
113
+ }
114
+ // Explicit only — never `/* /index.html 200`
115
+ const redirectsText = redirectLines.join('\n') + (redirectLines.length ? '\n' : '');
116
+ if (/\*\s+\/index\.html/.test(redirectsText)) {
117
+ throw new Error('netlify adapter refused SPA fallback redirect');
118
+ }
119
+ return {
120
+ schema: CDN_ADAPTER_PROJECTION_SCHEMA,
121
+ adapterId: 'netlify',
122
+ policyDigest: policy.policyDigest,
123
+ host: 'netlify',
124
+ spaFallback: false,
125
+ redirects: policy.redirects,
126
+ headers: policy.headers,
127
+ errorDocuments: policy.errorDocuments,
128
+ files: {
129
+ _headers: headerLines.join('\n') + '\n',
130
+ _redirects: redirectsText,
131
+ },
132
+ };
133
+ }
134
+ throw new Error(`unknown CDN adapter ${adapterId}`);
135
+ }
136
+ /**
137
+ * Local static host that applies CDNPolicyManifest (redirects, cache headers, 404 doc).
138
+ * @param {string} distDir
139
+ * @param {Record<string, any>} policy
140
+ * @param {{ host?: string, port?: number }} [opts]
141
+ * @returns {Promise<{ host: string, port: number, baseUrl: string, close: () => Promise<void> }>}
142
+ */
143
+ export function listenLocalStaticHost(distDir, policy, opts = {}) {
144
+ const host = opts.host || '127.0.0.1';
145
+ const port = Number(opts.port || 0);
146
+ const handler = createLocalStaticHandler(distDir, policy);
147
+ const server = http.createServer(handler);
148
+ return new Promise((resolve, reject) => {
149
+ server.listen(port, host, () => {
150
+ const addr = server.address();
151
+ const actualPort = typeof addr === 'object' && addr ? addr.port : port;
152
+ resolve({
153
+ host,
154
+ port: actualPort,
155
+ baseUrl: `http://${host}:${actualPort}`,
156
+ close: () => new Promise((res, rej) => {
157
+ server.close((err) => (err ? rej(err) : res()));
158
+ }),
159
+ });
160
+ });
161
+ server.on('error', reject);
162
+ });
163
+ }
164
+ /**
165
+ * @param {string} distDir
166
+ * @param {Record<string, any>} policy
167
+ */
168
+ export function createLocalStaticHandler(distDir, policy) {
169
+ const root = path.resolve(distDir);
170
+ return (req, res) => {
171
+ try {
172
+ const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
173
+ let pathname = decodeURIComponent(url.pathname || '/');
174
+ const redirect = matchRedirect(policy.redirects || [], pathname);
175
+ if (redirect) {
176
+ const status = Number(redirect.status) || 301;
177
+ const headers = applyHeaderRules(policy.headers || [], pathname, {
178
+ Location: redirect.to,
179
+ });
180
+ res.writeHead(status, headers);
181
+ res.end();
182
+ return;
183
+ }
184
+ let rel = pathname;
185
+ if (rel.endsWith('/'))
186
+ rel += 'index.html';
187
+ if (rel === '/')
188
+ rel = '/index.html';
189
+ const file = safeJoin(root, rel.replace(/^\//, ''));
190
+ if (file && fs.existsSync(file) && fs.statSync(file).isFile()) {
191
+ const body = fs.readFileSync(file);
192
+ const type = contentType(file);
193
+ const headers = applyHeaderRules(policy.headers || [], rel, {
194
+ 'content-type': type,
195
+ });
196
+ res.writeHead(200, headers);
197
+ res.end(body);
198
+ return;
199
+ }
200
+ const errDoc = (policy.errorDocuments || []).find((e) => Number(e.status) === 404);
201
+ const errPath = errDoc?.path || '404.html';
202
+ const abs404 = path.join(root, errPath);
203
+ if (fs.existsSync(abs404)) {
204
+ const body = fs.readFileSync(abs404);
205
+ const headers = applyHeaderRules(policy.headers || [], `/${errPath}`, {
206
+ 'content-type': 'text/html; charset=utf-8',
207
+ });
208
+ res.writeHead(404, headers);
209
+ res.end(body);
210
+ return;
211
+ }
212
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
213
+ res.end('not found');
214
+ }
215
+ catch (err) {
216
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
217
+ res.end(err instanceof Error ? err.message : String(err));
218
+ }
219
+ };
220
+ }
221
+ /**
222
+ * @param {Array<{ from: string, to: string, status?: number }>} redirects
223
+ * @param {string} pathname
224
+ */
225
+ function matchRedirect(redirects, pathname) {
226
+ const p = pathname.replace(/\/+$/, '') || '/';
227
+ for (const r of redirects) {
228
+ const from = String(r.from || '').replace(/\/+$/, '') || '/';
229
+ if (from === p || from === pathname)
230
+ return r;
231
+ }
232
+ return null;
233
+ }
234
+ /**
235
+ * @param {Array<{ match: string, headers: Record<string, string> }>} rules
236
+ * @param {string} pathname
237
+ * @param {Record<string, string>} base
238
+ */
239
+ function applyHeaderRules(rules, pathname, base = {}) {
240
+ /** @type {Record<string, string>} */
241
+ const out = { ...base };
242
+ for (const rule of rules) {
243
+ if (matchGlob(rule.match, pathname)) {
244
+ Object.assign(out, rule.headers || {});
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ /**
250
+ * Minimal glob matcher for CDN header rules (html / js|css|mjs / exact / prefix).
251
+ * @param {string} pattern
252
+ * @param {string} pathname
253
+ */
254
+ export function matchGlob(pattern, pathname) {
255
+ const p = pathname.startsWith('/') ? pathname : `/${pathname}`;
256
+ const pat = String(pattern || '');
257
+ if (pat === '**/*.html')
258
+ return p.endsWith('.html');
259
+ if (pat === '**/404.html')
260
+ return p === '/404.html' || p.endsWith('/404.html');
261
+ if (pat === '**/assets/**')
262
+ return p === '/assets' || p.startsWith('/assets/');
263
+ if (pat === '**/*.{js,css,mjs}')
264
+ return /\.(js|css|mjs)$/.test(p);
265
+ if (pat === '**/sitemap.xml')
266
+ return p.endsWith('/sitemap.xml') || p === '/sitemap.xml';
267
+ if (pat === '**/robots.txt')
268
+ return p.endsWith('/robots.txt') || p === '/robots.txt';
269
+ if (pat.endsWith('/**')) {
270
+ const prefix = pat.slice(0, -3);
271
+ // Only treat as path prefix when pattern is absolute-ish (starts with / or bare segment).
272
+ if (prefix.startsWith('/')) {
273
+ return p === prefix || p.startsWith(prefix + '/');
274
+ }
275
+ if (!prefix.includes('*')) {
276
+ return p === `/${prefix}` || p.startsWith(`/${prefix}/`);
277
+ }
278
+ }
279
+ return p === pat || p === `/${pat.replace(/^\//, '')}`;
280
+ }
281
+ function netlifyGlob(match) {
282
+ if (match === '**/*.html')
283
+ return '/*.html';
284
+ if (match === '**/404.html')
285
+ return '/404.html';
286
+ if (match === '**/assets/**')
287
+ return '/assets/*';
288
+ if (match === '**/*.{js,css,mjs}')
289
+ return '/*.{js,css,mjs}';
290
+ if (match === '**/sitemap.xml')
291
+ return '/sitemap.xml';
292
+ if (match === '**/robots.txt')
293
+ return '/robots.txt';
294
+ return match.startsWith('/') ? match : `/${match}`;
295
+ }
296
+ function headerCase(name) {
297
+ return String(name)
298
+ .split('-')
299
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
300
+ .join('-');
301
+ }
302
+ function contentType(file) {
303
+ if (file.endsWith('.html'))
304
+ return 'text/html; charset=utf-8';
305
+ if (file.endsWith('.js') || file.endsWith('.mjs'))
306
+ return 'text/javascript; charset=utf-8';
307
+ if (file.endsWith('.css'))
308
+ return 'text/css; charset=utf-8';
309
+ if (file.endsWith('.xml'))
310
+ return 'application/xml';
311
+ if (file.endsWith('.txt'))
312
+ return 'text/plain; charset=utf-8';
313
+ if (file.endsWith('.json'))
314
+ return 'application/json; charset=utf-8';
315
+ return 'application/octet-stream';
316
+ }
317
+ /**
318
+ * @param {string} root
319
+ * @param {string} rel
320
+ */
321
+ function safeJoin(root, rel) {
322
+ const full = path.resolve(root, rel);
323
+ const normRoot = path.resolve(root);
324
+ if (full !== normRoot && !full.startsWith(normRoot + path.sep))
325
+ return null;
326
+ return full;
327
+ }
328
+ function sha256Hex(data) {
329
+ return crypto.createHash('sha256').update(data).digest('hex');
330
+ }
331
+ function canonicalJson(value) {
332
+ return JSON.stringify(sortKeys(value));
333
+ }
334
+ function sortKeys(value) {
335
+ if (Array.isArray(value))
336
+ return value.map(sortKeys);
337
+ if (value && typeof value === 'object') {
338
+ const out = {};
339
+ for (const k of Object.keys(value).sort())
340
+ out[k] = sortKeys(value[k]);
341
+ return out;
342
+ }
343
+ return value;
344
+ }
package/dist/cli.js CHANGED
@@ -9,15 +9,21 @@ import { HOST_PROTOCOL, createWorkspace, getProtocolVersions, resolveCoreRuntime
9
9
  import { createDevSession } from './dev-session.js';
10
10
  import { gateGlobalProjectCommand, getInvocationContext, isGlobalAllowedCommand } from './invocation.js';
11
11
  import { log } from './log.js';
12
+ import { findAvailablePort } from './port.js';
12
13
  import { readPackageMeta, resolveWorkspaceDirs } from './resolve.js';
13
14
  import { cmdTest } from './test-cmd.js';
14
15
  import { cmdDocument } from './document-cmd.js';
15
16
  import { buildIntegratedDocuments, projectHasDocuments } from './document-integrate.js';
16
17
  import { cmdLocale } from './locale-cmd.js';
18
+ import { emitLocaleRuntimeModules, localeHasErrors } from './locale-check.js';
17
19
  import { cmdApplication } from './application-cmd.js';
20
+ import { cmdArtifact } from './release-cmd.js';
18
21
  import { cmdRefactor } from './refactor-cmd.js';
19
22
  import { cmdExplain } from './explain-cmd.js';
20
23
  import { resolveNativeVmzCli } from './resolve-native-cli.js';
24
+ import { emitWebStatic } from './static-emit.js';
25
+ import { emitSiteDelivery } from './site-delivery.js';
26
+ import { loadVmzConfig } from './plugin-host.js';
21
27
  /**
22
28
  * @param {string[]} argv
23
29
  */
@@ -66,8 +72,11 @@ export function parseArgs(argv) {
66
72
  export function printGlobalHelp() {
67
73
  console.log(`vmz — global mode (scaffold only)
68
74
 
75
+ Install faces: @vmz/core (runtime) · @vmz/vmz (this CLI) · optional @vmz/ui / @vmz/plugin-*
76
+
77
+ Three install modes:
69
78
  developer monorepo source (packages/runtimes/vmz) — full CLI
70
- project app node_modules/@vmz/vmz (or vmz) — full CLI
79
+ project app node_modules/@vmz/vmz — full CLI
71
80
  global npm/pnpm -g — only new/init/help/version
72
81
 
73
82
  You are in global mode. Pin \`@vmz/vmz\` in the app so check/build/lsp
@@ -79,11 +88,11 @@ Usage:
79
88
  vmz help Show this help
80
89
 
81
90
  Project commands:
82
- pnpm add -D @vmz/vmz
91
+ pnpm add @vmz/core && pnpm add -D @vmz/vmz
83
92
  pnpm exec vmz check
84
93
  # or: vmz new my-app && cd my-app && pnpm install
85
94
 
86
- If a project \`node_modules/@vmz/vmz\` (or \`vmz\`) exists, a global
95
+ If a project \`node_modules/@vmz/vmz\` exists, a global
87
96
  \`vmz <cmd>\` re-execs that bin.
88
97
  `);
89
98
  }
@@ -101,6 +110,7 @@ Usage:
101
110
  vmz test [path] [options] Native test discover / report
102
111
  vmz document|docs <cmd> Project /documents domain
103
112
  vmz application <cmd> Application Collection / Mount
113
+ vmz artifact <cmd> Release pack / publish / rollback / diff (A3)
104
114
  vmz refactor <cmd> DX rename plans / apply
105
115
  vmz explain [style] <target> DX causal explain (style Theme chain)
106
116
  vmz lsp [root] [--out-dir] Language server (stdio; native CLI)
@@ -111,8 +121,10 @@ Usage:
111
121
  Options:
112
122
  --out-dir, -o <dir> Output directory (default: dist)
113
123
  --release Release build (build only)
124
+ --profile <name> Delivery profile after build (web-static)
125
+ --origin <url> Site origin for web-static canonical/sitemap
114
126
  --host <host> Listen host (default: 127.0.0.1)
115
- --port <port> Listen port (default: 5173)
127
+ --port <port> Listen port (dev: omit = auto from 5173; set = lock)
116
128
  --poll-ms <ms> Dev watch poll interval (default: 300)
117
129
  --build Build before serve
118
130
  --check Format check-only (format)
@@ -197,6 +209,10 @@ export async function runCli(argv, opts = {}) {
197
209
  case 'applications':
198
210
  case 'app':
199
211
  return cmdApplication(rest);
212
+ case 'artifact':
213
+ case 'artifacts':
214
+ case 'release':
215
+ return cmdArtifact(rest);
200
216
  case 'refactor':
201
217
  return cmdRefactor(rest);
202
218
  case 'explain':
@@ -278,7 +294,7 @@ function cmdCheck(args) {
278
294
  * @param {import('./index.js').Workspace} ws
279
295
  * @param {string} project
280
296
  * @param {string} outDir
281
- * @param { => Promise<number> | number} fn
297
+ * @param {() => Promise<number> | number} fn
282
298
  */
283
299
  async function runWithPlugins(ws, project, outDir, fn) {
284
300
  const { loadVmzConfig, applyPlugins } = await import('./plugin-host.js');
@@ -313,11 +329,39 @@ async function cmdBuild(args) {
313
329
  });
314
330
  if (code !== 0)
315
331
  return code;
332
+ const localeEmit = emitLocaleRuntimeModules(project, outDir);
333
+ if (!localeEmit.ok || localeHasErrors({ diagnostics: localeEmit.diagnostics })) {
334
+ log.diagnostics(localeEmit.diagnostics ?? []);
335
+ log.error('locale runtime emit failed');
336
+ return 1;
337
+ }
338
+ if (localeEmit.written.length) {
339
+ log.info(`locale runtime emit (${localeEmit.written.length} module(s))`);
340
+ }
316
341
  if (projectHasDocuments(project)) {
317
342
  const docs = await buildIntegratedDocuments({ projectRoot: project, outDir });
318
343
  if (!docs.ok)
319
344
  return 1;
320
345
  }
346
+ const cfg = await loadVmzConfig(project);
347
+ if (cfg.delivery) {
348
+ log.info(`site-delivery emit ${outDir}`);
349
+ const site = emitSiteDelivery(outDir, cfg.delivery, {
350
+ siteId: cfg.application?.id || undefined,
351
+ });
352
+ log.info(`site-delivery ok (digest=${String(site.contract.contractDigest).slice(0, 12)}…)`);
353
+ }
354
+ const profile = typeof args.profile === 'string' ? args.profile : '';
355
+ if (profile === 'web-static') {
356
+ const origin = typeof args.origin === 'string' ? args.origin : undefined;
357
+ log.info(`web-static emit ${outDir}`);
358
+ const result = await emitWebStatic(outDir, { origin });
359
+ log.info(`web-static ok (${result.htmlFiles.length} html, ${result.skipped.length} skipped, digest=${result.digest.slice(0, 12)}…)`);
360
+ }
361
+ else if (profile) {
362
+ log.error(`unknown build --profile ${profile} (supported: web-static)`);
363
+ return 1;
364
+ }
321
365
  return 0;
322
366
  }
323
367
  finally {
@@ -391,7 +435,22 @@ async function cmdDev(args) {
391
435
  outDir: typeof args['out-dir'] === 'string' ? args['out-dir'] : undefined,
392
436
  });
393
437
  const host = typeof args.host === 'string' ? args.host : '127.0.0.1';
394
- const port = Number(args.port ?? 5173);
438
+ const portLocked = Object.prototype.hasOwnProperty.call(args, 'port');
439
+ let port;
440
+ if (portLocked) {
441
+ port = Number(args.port);
442
+ if (!Number.isFinite(port) || port <= 0) {
443
+ log.error(`invalid --port ${String(args.port)}`);
444
+ return 1;
445
+ }
446
+ }
447
+ else {
448
+ const preferred = 5173;
449
+ port = await findAvailablePort(host, preferred);
450
+ if (port !== preferred) {
451
+ log.info(`port ${preferred} busy → using ${port}`);
452
+ }
453
+ }
395
454
  const pollMs = Number(args['poll-ms'] ?? 300);
396
455
  const ac = new AbortController();
397
456
  const onSig = () => {