@volter/world-runtime 3.0.145 → 3.0.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/covers.js +4 -3
- package/dist/src/env-output.d.ts +1 -0
- package/dist/src/env-output.js +2 -1
- package/dist/src/init.d.ts +2 -0
- package/dist/src/init.js +2 -2
- package/dist/src/runtime.js +16 -4
- package/dist/src/schema.d.ts +5 -0
- package/dist/src/served-world.js +39 -6
- package/dist/src/up-cli.d.ts +1 -1
- package/dist/src/up-cli.js +4 -4
- package/package.json +4 -4
- package/src/covers.ts +4 -3
- package/src/env-output.ts +5 -2
- package/src/init.ts +4 -2
- package/src/runtime.ts +16 -4
- package/src/schema.ts +2 -0
- package/src/served-world.ts +32 -6
- package/src/up-cli.ts +4 -4
package/dist/src/covers.js
CHANGED
|
@@ -994,11 +994,12 @@ export function formatCoverageReport(report) {
|
|
|
994
994
|
lines.push(`ACKNOWLEDGED: ${report.acknowledged.length} signal(s) accepted with recorded reasons (no twin): ${report.acknowledged.map((a) => a.vendor).join(', ')}`);
|
|
995
995
|
}
|
|
996
996
|
if (report.excluded.length > 0)
|
|
997
|
-
lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (
|
|
997
|
+
lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (outside automatic selection; declared service bindings are reported separately).`);
|
|
998
|
+
const detectionScope = report.excluded.length > 0 ? 'selected detected vendor' : 'detected vendor';
|
|
998
999
|
lines.push(report.ok
|
|
999
1000
|
? report.acknowledged.length > 0
|
|
1000
|
-
? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other
|
|
1001
|
-
: `COVERED: every
|
|
1001
|
+
? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other ${detectionScope} has a twin in world ${report.world}.`
|
|
1002
|
+
: `COVERED: every ${detectionScope} has a twin in world ${report.world}.`
|
|
1002
1003
|
: `PROOF FAILED for world ${report.world}.`);
|
|
1003
1004
|
lines.push('Static connection check only; run an app scenario to verify behavior and shared state.');
|
|
1004
1005
|
return `${lines.join('\n')}\n`;
|
package/dist/src/env-output.d.ts
CHANGED
package/dist/src/env-output.js
CHANGED
|
@@ -17,7 +17,8 @@ function inspect(path) {
|
|
|
17
17
|
/** Refuse a custody/input file before any World service or lifecycle mutation starts. */
|
|
18
18
|
export function claimEnvOutput(path, backupDir, priors = [], force = false) {
|
|
19
19
|
const contents = inspect(path);
|
|
20
|
-
if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && Buffer.from(prior.contents).equals(contents)
|
|
20
|
+
if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && (Buffer.from(prior.contents).equals(contents)
|
|
21
|
+
|| /^[a-f0-9]{64}$/.test(prior.sha256 ?? '') && createHash('sha256').update(contents).digest('hex') === prior.sha256))) {
|
|
21
22
|
throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
|
|
22
23
|
}
|
|
23
24
|
if (force && contents !== undefined) {
|
package/dist/src/init.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export type EnvDisposition =
|
|
|
37
37
|
'faked'
|
|
38
38
|
/** a credential the vendor's twin issues at boot → `$issue:<vendor>` (the twin refuses a key it did not make) */
|
|
39
39
|
| 'issued'
|
|
40
|
+
/** a World-owned endpoint or reserved environment name → supplied at boot, never copied from the app */
|
|
41
|
+
| 'injected'
|
|
40
42
|
/** non-credential app config → the example value copied verbatim */
|
|
41
43
|
| 'kept'
|
|
42
44
|
/** infra connection string → points at a World-managed infrastructure service */
|
package/dist/src/init.js
CHANGED
|
@@ -734,7 +734,7 @@ export function planWorldInit(name, repoPath, options = {}) {
|
|
|
734
734
|
// World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
|
|
735
735
|
// stale copy would shadow the live one.
|
|
736
736
|
if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name) || worldInjected.has(name)) {
|
|
737
|
-
envRows.push({ name, disposition: '
|
|
737
|
+
envRows.push({ name, disposition: 'injected', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
|
|
738
738
|
continue;
|
|
739
739
|
}
|
|
740
740
|
// The application's own origin (its base URL, its auth library's URL): only the booter knows where the app is
|
|
@@ -1319,7 +1319,7 @@ export function formatInitReport(result) {
|
|
|
1319
1319
|
lines.push('');
|
|
1320
1320
|
const counts = (disposition) => plan.envRows.filter((row) => row.disposition === disposition);
|
|
1321
1321
|
lines.push(`Env (${plan.envRows.length} name(s) from ${plan.envSources.join(', ') || 'no example env file'}):`);
|
|
1322
|
-
for (const disposition of ['faked', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown']) {
|
|
1322
|
+
for (const disposition of ['faked', 'issued', 'injected', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown']) {
|
|
1323
1323
|
const rows = counts(disposition);
|
|
1324
1324
|
if (rows.length === 0)
|
|
1325
1325
|
continue;
|
package/dist/src/runtime.js
CHANGED
|
@@ -669,8 +669,8 @@ function readWorldBootRecordLocked(name, root) {
|
|
|
669
669
|
}
|
|
670
670
|
if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
|
|
671
671
|
|| new Set(record.bootServices).size !== record.bootServices.length
|
|
672
|
-
||
|
|
673
|
-
|| record.bootServices.
|
|
672
|
+
|| Object.keys(record.services).some(id => !record.bootServices.includes(id))
|
|
673
|
+
|| !stopped && record.bootServices.length !== Object.keys(record.services).length)
|
|
674
674
|
throw new Error();
|
|
675
675
|
for (const [id, service] of Object.entries(record.services)) {
|
|
676
676
|
if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string')
|
|
@@ -695,7 +695,7 @@ function readWorldBootRecordLocked(name, root) {
|
|
|
695
695
|
return record;
|
|
696
696
|
}
|
|
697
697
|
catch {
|
|
698
|
-
throw new Error(`World boot record is incomplete or invalid: ${path}
|
|
698
|
+
throw new Error(`World boot record is incomplete or invalid: ${path}\nFrom the app directory, inspect: volter-world doctor ${name} --root .\nComplete teardown with the owning runtime: volter-world down ${name} --root .\nRetain the startup diagnostics and any history. Only a disposable instance with no state to keep may be removed with down --purge; resume never discards it automatically.`);
|
|
699
699
|
}
|
|
700
700
|
}
|
|
701
701
|
export function admittedEnv(strip, env = process.env) {
|
|
@@ -2043,7 +2043,9 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2043
2043
|
return [];
|
|
2044
2044
|
try {
|
|
2045
2045
|
const prior = JSON.parse(readFileSync(file, 'utf8'));
|
|
2046
|
-
return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env)
|
|
2046
|
+
return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env),
|
|
2047
|
+
...(prior.previousEnvOutput?.path === resolve(prior.envFile) ? { sha256: prior.previousEnvOutput.sha256 } : {}),
|
|
2048
|
+
}] : [];
|
|
2047
2049
|
}
|
|
2048
2050
|
catch {
|
|
2049
2051
|
return [];
|
|
@@ -2248,6 +2250,7 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2248
2250
|
const worldInstance = {
|
|
2249
2251
|
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2250
2252
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2253
|
+
...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
|
|
2251
2254
|
networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]))),
|
|
2252
2255
|
bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
|
|
2253
2256
|
...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
|
|
@@ -2416,7 +2419,12 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
2416
2419
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
2417
2420
|
// Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
|
|
2418
2421
|
// the mode the caller chose (a shared env file may be read under another uid).
|
|
2422
|
+
// Persist final environment identity while the claimed old output is still recoverable; caller loss between
|
|
2423
|
+
// this record and publication must not make the old file appear to be custody.
|
|
2424
|
+
saveWorldInstance(worldInstance);
|
|
2419
2425
|
publishEnvOutput(envOutputClaim, envFileContents(env));
|
|
2426
|
+
delete worldInstance.previousEnvOutput;
|
|
2427
|
+
saveWorldInstance(worldInstance);
|
|
2420
2428
|
boot.progress(`environment written: ${envFile}`);
|
|
2421
2429
|
materializeBrowserFiles(data, root, name, loaded.config.id);
|
|
2422
2430
|
writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
|
|
@@ -3562,6 +3570,10 @@ export async function doctorWorld(name, options = {}) {
|
|
|
3562
3570
|
return { name, ok: false, checks: [{ id: 'instance', ok: false, message }] };
|
|
3563
3571
|
}
|
|
3564
3572
|
checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
|
|
3573
|
+
const missingServices = (instance.bootServices ?? []).filter(id => !Object.hasOwn(instance.services, id));
|
|
3574
|
+
checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
|
|
3575
|
+
? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
|
|
3576
|
+
: 'declared boot services have instance records' });
|
|
3565
3577
|
if (instance.lifecycle) {
|
|
3566
3578
|
checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
|
|
3567
3579
|
message: `lifecycle ownership recorded; no capacity reserved; log=${instance.lifecycle.log}` });
|
package/dist/src/schema.d.ts
CHANGED
|
@@ -532,6 +532,11 @@ export type WorldInstance = {
|
|
|
532
532
|
bootServices?: string[];
|
|
533
533
|
bootOwner?: string | null;
|
|
534
534
|
envFile: string;
|
|
535
|
+
/** Exact output claimed before this boot replaced its predecessor; retained only until publication completes. */
|
|
536
|
+
previousEnvOutput?: {
|
|
537
|
+
path: string;
|
|
538
|
+
sha256: string;
|
|
539
|
+
};
|
|
535
540
|
pidsFile: string;
|
|
536
541
|
actors?: Record<string, unknown>;
|
|
537
542
|
fixtures?: Record<string, unknown>;
|
package/dist/src/served-world.js
CHANGED
|
@@ -1271,6 +1271,7 @@ export class WorldDoors {
|
|
|
1271
1271
|
if (template)
|
|
1272
1272
|
headers.set(TWIN_SITES_HEADER, template);
|
|
1273
1273
|
headers.set(TWIN_SITE_HEADER, pageHost);
|
|
1274
|
+
headers.set(TWIN_PREFIX_HEADER, '/');
|
|
1274
1275
|
headers.set('x-forwarded-host', url.host);
|
|
1275
1276
|
headers.set('x-forwarded-proto', url.protocol.slice(0, -1));
|
|
1276
1277
|
}
|
|
@@ -1460,6 +1461,10 @@ export class WorldDoors {
|
|
|
1460
1461
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
1461
1462
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
1462
1463
|
const link = this.link(vendor);
|
|
1464
|
+
const screenMount = tail[0] === '_screen';
|
|
1465
|
+
// A declared screen mount is answered only by the twin, never forwarded to a real-system link.
|
|
1466
|
+
if (screenMount && link)
|
|
1467
|
+
return Response.json({ error: `${vendor} is linked to the vendor itself: no twin screen is mounted` }, { status: 404 });
|
|
1463
1468
|
// a link spends the World's sealed vendor credential: never from the content plane, where any twin's markup runs
|
|
1464
1469
|
if (link && this.onPages(url))
|
|
1465
1470
|
return Response.json({ error: `${vendor} is linked to the vendor itself: it is used through the World's own origin` }, { status: 404 });
|
|
@@ -1471,6 +1476,22 @@ export class WorldDoors {
|
|
|
1471
1476
|
const twin = this.host.twinFetch(vendor);
|
|
1472
1477
|
if (!twin)
|
|
1473
1478
|
return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
|
|
1479
|
+
// The Board's screen mount keeps the hostname the workspace declared. Without it, a multi-lane vendor's
|
|
1480
|
+
// /redis console page is sent to its Redis data API. The mount is the World's word, checked against discovery;
|
|
1481
|
+
// it survives redirects and forms through the public prefix instead of trusting a browser's routing header.
|
|
1482
|
+
let screenHost;
|
|
1483
|
+
if (screenMount) {
|
|
1484
|
+
try {
|
|
1485
|
+
screenHost = decodeURIComponent(tail[1] ?? '').toLowerCase();
|
|
1486
|
+
}
|
|
1487
|
+
catch {
|
|
1488
|
+
return Response.json({ error: 'invalid screen hostname' }, { status: 400 });
|
|
1489
|
+
}
|
|
1490
|
+
if (!screenHost || !(await this.screensOf(vendor)).some(screen => screen.host?.toLowerCase() === screenHost)) {
|
|
1491
|
+
return Response.json({ error: `no built workspace of ${vendor} declares that screen hostname` }, { status: 404 });
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
const vendorPath = (screenMount ? tail.slice(2) : tail).join('/');
|
|
1474
1495
|
// THE READ SCOPE: the twin is told the request is read-only and refuses the caller's writes itself
|
|
1475
1496
|
// (a Slack read over POST passes, a post is refused); a twin that cannot enforce that gets reads only
|
|
1476
1497
|
const enforced = scope === 'read' && (await this.enforcesRead(vendor, twin, url.origin));
|
|
@@ -1505,7 +1526,9 @@ export class WorldDoors {
|
|
|
1505
1526
|
else
|
|
1506
1527
|
headers.delete('cookie');
|
|
1507
1528
|
// the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
|
|
1508
|
-
headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
|
|
1529
|
+
headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}${screenHost ? `/_screen/${encodeURIComponent(screenHost)}` : ''}`);
|
|
1530
|
+
if (screenHost)
|
|
1531
|
+
headers.set('x-volter-twin-original-host', screenHost);
|
|
1509
1532
|
headers.set('x-forwarded-host', url.host);
|
|
1510
1533
|
headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
1511
1534
|
// where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
|
|
@@ -1516,7 +1539,7 @@ export class WorldDoors {
|
|
|
1516
1539
|
else
|
|
1517
1540
|
headers.delete(TWIN_SITES_HEADER);
|
|
1518
1541
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
1519
|
-
const answer = await twin(new Request(`${url.origin}/${
|
|
1542
|
+
const answer = await twin(new Request(`${url.origin}/${vendorPath}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
1520
1543
|
// an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
|
|
1521
1544
|
if (answer.status === 101)
|
|
1522
1545
|
return answer;
|
|
@@ -1802,6 +1825,8 @@ export class WorldDoors {
|
|
|
1802
1825
|
return new Response(null, { status: 204, headers });
|
|
1803
1826
|
}
|
|
1804
1827
|
if (request.method === 'PUT') {
|
|
1828
|
+
if (request.headers.get('if-none-match') === '*' && worldBrowser(data, name))
|
|
1829
|
+
return Response.json({ error: `browser ${name} already exists; choose it or use another name` }, { status: 412 });
|
|
1805
1830
|
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? ''))
|
|
1806
1831
|
return Response.json({ error: 'browser state is application/json' }, { status: 415 });
|
|
1807
1832
|
if (!worldBrowser(data, name) && worldBrowsers(data).length >= BROWSER_COUNT_LIMIT)
|
|
@@ -2383,7 +2408,10 @@ export class WorldDoors {
|
|
|
2383
2408
|
const origin = readBranchMeta(state, this.controlRoot(name))?.origin;
|
|
2384
2409
|
const identity = { mounted: mounted?.resolvedPackage ? { package: mounted.resolvedPackage, version: mounted.resolvedVersion ?? null } : null, pinned: mounted?.pinnedSource ?? null };
|
|
2385
2410
|
const dataOrigin = origin ? { kind: 'shared-history', url: origin.at } : refresh ? { kind: 'vendor-refresh', url: root?.url ?? null } : { kind: 'unrecorded', url: null };
|
|
2386
|
-
|
|
2411
|
+
const claimed = await this.claimedHosts(url.origin);
|
|
2412
|
+
const sites = (await this.browserSites(url.origin, claimed)).find(row => row.vendor === name)?.hosts ?? [];
|
|
2413
|
+
const readOnlySites = claimed.find(row => row.vendor === name)?.hosts ?? [];
|
|
2414
|
+
return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), sites, readOnlySites, resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
|
|
2387
2415
|
}
|
|
2388
2416
|
if (verb === 'refresh' && request.method === 'POST') {
|
|
2389
2417
|
const root = rootForControlRoot(this.controlRoot(name), name);
|
|
@@ -2719,9 +2747,14 @@ export class WorldDoors {
|
|
|
2719
2747
|
// a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
|
|
2720
2748
|
// one), whose changes are all of the vendor's
|
|
2721
2749
|
const fallback = !listed.length;
|
|
2750
|
+
const screens = await this.screensOf(vendor);
|
|
2751
|
+
const screenPath = (path, hostname) => {
|
|
2752
|
+
const host = hostname?.toLowerCase();
|
|
2753
|
+
return host && !/[{<]/.test(host) && screens.some(screen => screen.host?.toLowerCase() === host)
|
|
2754
|
+
? `/_screen/${encodeURIComponent(host)}${path}` : path;
|
|
2755
|
+
};
|
|
2722
2756
|
let homeHost;
|
|
2723
2757
|
if (fallback) {
|
|
2724
|
-
const screens = await this.screensOf(vendor);
|
|
2725
2758
|
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
2726
2759
|
homeHost = home?.host;
|
|
2727
2760
|
if (home)
|
|
@@ -2785,7 +2818,7 @@ export class WorldDoors {
|
|
|
2785
2818
|
at = { site: opened };
|
|
2786
2819
|
}
|
|
2787
2820
|
else if (f.drawn)
|
|
2788
|
-
at = { screen: `${u.pathname}${u.search}
|
|
2821
|
+
at = { screen: screenPath(`${u.pathname}${u.search}`, u.hostname) };
|
|
2789
2822
|
if (!at)
|
|
2790
2823
|
unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
2791
2824
|
}
|
|
@@ -2796,7 +2829,7 @@ export class WorldDoors {
|
|
|
2796
2829
|
unseen = 'not-drawn';
|
|
2797
2830
|
}
|
|
2798
2831
|
if (fallback)
|
|
2799
|
-
at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}
|
|
2832
|
+
at = { screen: screenPath(f.url.startsWith('/') ? f.url : `/${f.url}`, homeHost) };
|
|
2800
2833
|
add(at, undefined, unseen);
|
|
2801
2834
|
}
|
|
2802
2835
|
}
|
package/dist/src/up-cli.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { upWorld } from './runtime.js';
|
|
2
2
|
export declare function upCommand(subject: string | undefined, rest: string[], root: string): Promise<void>;
|
|
3
|
-
export declare function printReady(instance: Awaited<ReturnType<typeof upWorld
|
|
3
|
+
export declare function printReady(instance: Awaited<ReturnType<typeof upWorld>>, fresh?: boolean): void;
|
package/dist/src/up-cli.js
CHANGED
|
@@ -44,7 +44,7 @@ export async function upCommand(subject, rest, root) {
|
|
|
44
44
|
if (rest.includes('--json'))
|
|
45
45
|
process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
|
|
46
46
|
else
|
|
47
|
-
printReady(instance);
|
|
47
|
+
printReady(instance, !keepState);
|
|
48
48
|
}
|
|
49
49
|
async function upInProcess(configId, options) {
|
|
50
50
|
const { upWorld } = await import("./runtime.js");
|
|
@@ -60,7 +60,7 @@ async function upInProcess(configId, options) {
|
|
|
60
60
|
process.off('SIGTERM', cancel);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
-
export function printReady(instance) {
|
|
63
|
+
export function printReady(instance, fresh = false) {
|
|
64
64
|
process.stdout.write(`World ${instance.name} is ready (${instance.config})
|
|
65
65
|
Instance: ${instance.instance}
|
|
66
66
|
Created: ${instance.createdAt}
|
|
@@ -68,8 +68,8 @@ Created: ${instance.createdAt}
|
|
|
68
68
|
Services:
|
|
69
69
|
${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
${fresh ? 'This is a fresh instance. Set the clock and run your seed before the application.'
|
|
72
|
+
: 'This World retains its branch state. Continue with the application; do not seed its records again.'}
|
|
73
73
|
|
|
74
74
|
The two moves: if the twin stores it, create it through the vendor's own API (seed as the
|
|
75
75
|
person, move the clock first for history). Everything else — judgment, lookups, faults — is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.147",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -70,14 +70,14 @@
|
|
|
70
70
|
"node": ">=22.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@volter/world-core": "3.0.
|
|
74
|
-
"@volter/world-console": "3.0.
|
|
73
|
+
"@volter/world-core": "3.0.147",
|
|
74
|
+
"@volter/world-console": "3.0.142"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
77
|
"@electric-sql/pglite": "0.5.8",
|
|
78
78
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
79
79
|
"@volter/world-access": "3.0.1",
|
|
80
|
-
"@volter/world-core": "3.0.
|
|
80
|
+
"@volter/world-core": "3.0.147",
|
|
81
81
|
"mongodb": "~6.20.0",
|
|
82
82
|
"pg-gateway": "0.3.0-beta.4",
|
|
83
83
|
"smol-toml": "^1.8.0",
|
package/src/covers.ts
CHANGED
|
@@ -1125,11 +1125,12 @@ export function formatCoverageReport(report: CoverageReport): string {
|
|
|
1125
1125
|
if (report.acknowledged.length > 0) {
|
|
1126
1126
|
lines.push(`ACKNOWLEDGED: ${report.acknowledged.length} signal(s) accepted with recorded reasons (no twin): ${report.acknowledged.map((a) => a.vendor).join(', ')}`);
|
|
1127
1127
|
}
|
|
1128
|
-
if (report.excluded.length > 0) lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (
|
|
1128
|
+
if (report.excluded.length > 0) lines.push(`EXCLUDED BY SELECTION: ${report.excluded.join(', ')} (outside automatic selection; declared service bindings are reported separately).`);
|
|
1129
|
+
const detectionScope = report.excluded.length > 0 ? 'selected detected vendor' : 'detected vendor';
|
|
1129
1130
|
lines.push(report.ok
|
|
1130
1131
|
? report.acknowledged.length > 0
|
|
1131
|
-
? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other
|
|
1132
|
-
: `COVERED: every
|
|
1132
|
+
? `COVERED (with ${report.acknowledged.length} acknowledgment(s)): every other ${detectionScope} has a twin in world ${report.world}.`
|
|
1133
|
+
: `COVERED: every ${detectionScope} has a twin in world ${report.world}.`
|
|
1133
1134
|
: `PROOF FAILED for world ${report.world}.`);
|
|
1134
1135
|
lines.push('Static connection check only; run an app scenario to verify behavior and shared state.');
|
|
1135
1136
|
return `${lines.join('\n')}\n`;
|
package/src/env-output.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { constants, closeSync, existsSync, fstatSync, ftruncateSync, lstatSync,
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
|
|
5
|
-
type PriorOutput = { path: string; contents: string };
|
|
5
|
+
type PriorOutput = { path: string; contents: string; sha256?: string };
|
|
6
6
|
export type EnvOutputClaim = { path: string; contents?: Buffer; force: boolean; backupDir: string };
|
|
7
7
|
|
|
8
8
|
function inspect(path: string): Buffer | undefined {
|
|
@@ -19,7 +19,10 @@ function inspect(path: string): Buffer | undefined {
|
|
|
19
19
|
/** Refuse a custody/input file before any World service or lifecycle mutation starts. */
|
|
20
20
|
export function claimEnvOutput(path: string, backupDir: string, priors: PriorOutput[] = [], force = false): EnvOutputClaim {
|
|
21
21
|
const contents = inspect(path);
|
|
22
|
-
if (contents !== undefined && !force && !priors.some((prior) => prior.path === path &&
|
|
22
|
+
if (contents !== undefined && !force && !priors.some((prior) => prior.path === path && (
|
|
23
|
+
Buffer.from(prior.contents).equals(contents)
|
|
24
|
+
|| /^[a-f0-9]{64}$/.test(prior.sha256 ?? '') && createHash('sha256').update(contents).digest('hex') === prior.sha256
|
|
25
|
+
))) {
|
|
23
26
|
throw new Error(`World refuses existing --env-out file it does not own: ${path}. Choose a new output path; never use a custody file. --force explicitly replaces a file with a private backup.`);
|
|
24
27
|
}
|
|
25
28
|
if (force && contents !== undefined) {
|
package/src/init.ts
CHANGED
|
@@ -122,6 +122,8 @@ export type EnvDisposition =
|
|
|
122
122
|
| 'faked'
|
|
123
123
|
/** a credential the vendor's twin issues at boot → `$issue:<vendor>` (the twin refuses a key it did not make) */
|
|
124
124
|
| 'issued'
|
|
125
|
+
/** a World-owned endpoint or reserved environment name → supplied at boot, never copied from the app */
|
|
126
|
+
| 'injected'
|
|
125
127
|
/** non-credential app config → the example value copied verbatim */
|
|
126
128
|
| 'kept'
|
|
127
129
|
/** infra connection string → points at a World-managed infrastructure service */
|
|
@@ -926,7 +928,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
|
|
|
926
928
|
// World-owned names: the world INJECTS these at boot from the services above. Carrying a repo's
|
|
927
929
|
// stale copy would shadow the live one.
|
|
928
930
|
if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name) || worldInjected.has(name)) {
|
|
929
|
-
envRows.push({ name, disposition: '
|
|
931
|
+
envRows.push({ name, disposition: 'injected', source, reason: 'world-owned (injected at boot) — dropped from the emitted env' });
|
|
930
932
|
continue;
|
|
931
933
|
}
|
|
932
934
|
// The application's own origin (its base URL, its auth library's URL): only the booter knows where the app is
|
|
@@ -1514,7 +1516,7 @@ export function formatInitReport(result: InitResult): string {
|
|
|
1514
1516
|
lines.push('');
|
|
1515
1517
|
const counts = (disposition: EnvDisposition): InitEnvRow[] => plan.envRows.filter((row) => row.disposition === disposition);
|
|
1516
1518
|
lines.push(`Env (${plan.envRows.length} name(s) from ${plan.envSources.join(', ') || 'no example env file'}):`);
|
|
1517
|
-
for (const disposition of ['faked', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown'] as EnvDisposition[]) {
|
|
1519
|
+
for (const disposition of ['faked', 'issued', 'injected', 'kept', 'app-origin', 'stand-in', 'compose', 'placeholder', 'unknown'] as EnvDisposition[]) {
|
|
1518
1520
|
const rows = counts(disposition);
|
|
1519
1521
|
if (rows.length === 0) continue;
|
|
1520
1522
|
lines.push(` ${disposition.padEnd(11)} ${String(rows.length).padStart(3)} ${rows.map((row) => row.name).join(', ')}`);
|
package/src/runtime.ts
CHANGED
|
@@ -698,8 +698,8 @@ function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
|
|
|
698
698
|
}
|
|
699
699
|
if (!Array.isArray(record.bootServices) || record.bootServices.some(id => typeof id !== 'string')
|
|
700
700
|
|| new Set(record.bootServices).size !== record.bootServices.length
|
|
701
|
-
||
|
|
702
|
-
|| record.bootServices.
|
|
701
|
+
|| Object.keys(record.services).some(id => !record.bootServices!.includes(id))
|
|
702
|
+
|| !stopped && record.bootServices.length !== Object.keys(record.services).length) throw new Error();
|
|
703
703
|
for (const [id, service] of Object.entries(record.services)) {
|
|
704
704
|
if (!service || service.id !== id || !Array.isArray(service.command) || typeof service.type !== 'string') throw new Error();
|
|
705
705
|
if (!Object.hasOwn(service, 'branchState')) {
|
|
@@ -716,7 +716,7 @@ function readWorldBootRecordLocked(name: string, root: string): WorldInstance {
|
|
|
716
716
|
instanceHash: createHash('sha256').update(readFileSync(path, 'utf8')).digest('hex') }) + '\n');
|
|
717
717
|
}
|
|
718
718
|
return record;
|
|
719
|
-
} catch { throw new Error(`World boot record is incomplete or invalid: ${path}
|
|
719
|
+
} catch { throw new Error(`World boot record is incomplete or invalid: ${path}\nFrom the app directory, inspect: volter-world doctor ${name} --root .\nComplete teardown with the owning runtime: volter-world down ${name} --root .\nRetain the startup diagnostics and any history. Only a disposable instance with no state to keep may be removed with down --purge; resume never discards it automatically.`); }
|
|
720
720
|
}
|
|
721
721
|
|
|
722
722
|
export function admittedEnv(strip: string[] | undefined, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
@@ -1961,7 +1961,9 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1961
1961
|
if (!existsSync(file)) return [];
|
|
1962
1962
|
try {
|
|
1963
1963
|
const prior = JSON.parse(readFileSync(file, 'utf8')) as WorldInstance;
|
|
1964
|
-
return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env)
|
|
1964
|
+
return prior?.name === world && resolve(prior.root) === root ? [{ path: resolve(prior.envFile), contents: envFileContents(prior.env),
|
|
1965
|
+
...(prior.previousEnvOutput?.path === resolve(prior.envFile) ? { sha256: prior.previousEnvOutput.sha256 } : {}),
|
|
1966
|
+
}] : [];
|
|
1965
1967
|
} catch { return []; }
|
|
1966
1968
|
});
|
|
1967
1969
|
const envOutputClaim = claimEnvOutput(envFile, join(root, stateDirName(), 'env-output-backups'), priorOutputs, options.forceEnvOutput);
|
|
@@ -2129,6 +2131,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
2129
2131
|
const worldInstance: WorldInstance = {
|
|
2130
2132
|
name, instance: instanceToken, config: loaded.config.id, configPath: loaded.path, root, createdAt, mode,
|
|
2131
2133
|
dirs: { instance, logs, data }, services, env, envFile, pidsFile,
|
|
2134
|
+
...(envOutputClaim.contents === undefined ? {} : { previousEnvOutput: { path: envFile, sha256: createHash('sha256').update(envOutputClaim.contents).digest('hex') } }),
|
|
2132
2135
|
networkCeiling: env[WORLD_NETWORK_POLICY_ENV] === undefined ? 'unrestricted' : networkPermission(parseWorldNetworkPolicy(JSON.parse(env[WORLD_NETWORK_POLICY_ENV]!))),
|
|
2133
2136
|
bootServices: loaded.config.services.map(service => service.id), bootOwner: options.owner ?? null,
|
|
2134
2137
|
...(loaded.config.selection ? { selection: loaded.config.selection } : {}),
|
|
@@ -2274,7 +2277,12 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
2274
2277
|
mkdirSync(dirname(envFile), { recursive: true });
|
|
2275
2278
|
// Config env and discovered credentials: a file this writes is owner-only; a file the caller already made keeps
|
|
2276
2279
|
// the mode the caller chose (a shared env file may be read under another uid).
|
|
2280
|
+
// Persist final environment identity while the claimed old output is still recoverable; caller loss between
|
|
2281
|
+
// this record and publication must not make the old file appear to be custody.
|
|
2282
|
+
saveWorldInstance(worldInstance);
|
|
2277
2283
|
publishEnvOutput(envOutputClaim, envFileContents(env));
|
|
2284
|
+
delete worldInstance.previousEnvOutput;
|
|
2285
|
+
saveWorldInstance(worldInstance);
|
|
2278
2286
|
boot.progress(`environment written: ${envFile}`);
|
|
2279
2287
|
materializeBrowserFiles(data, root, name, loaded.config.id);
|
|
2280
2288
|
writePidsFromInstance(worldInstance, Object.values(worldInstance.services).map((service) => service.pid).filter((pid) => pid > 0));
|
|
@@ -3360,6 +3368,10 @@ export async function doctorWorld(name: string, options: { root?: string; verify
|
|
|
3360
3368
|
}
|
|
3361
3369
|
|
|
3362
3370
|
checks.push({ id: 'env-file', ok: existsSync(instance.envFile), message: instance.envFile });
|
|
3371
|
+
const missingServices = (instance.bootServices ?? []).filter(id => !Object.hasOwn(instance.services, id));
|
|
3372
|
+
checks.push({ id: 'startup', ok: missingServices.length === 0, message: missingServices.length
|
|
3373
|
+
? `startup incomplete: no ready service record for ${missingServices.join(', ')}; retained state requires normal teardown before resume`
|
|
3374
|
+
: 'declared boot services have instance records' });
|
|
3363
3375
|
|
|
3364
3376
|
if (instance.lifecycle) {
|
|
3365
3377
|
checks.push({ id: 'lifecycle', ok: hasWorldLifecycle(root, name) && existsSync(instance.lifecycle.log),
|
package/src/schema.ts
CHANGED
|
@@ -463,6 +463,8 @@ export type WorldInstance = {
|
|
|
463
463
|
bootServices?: string[];
|
|
464
464
|
bootOwner?: string | null;
|
|
465
465
|
envFile: string;
|
|
466
|
+
/** Exact output claimed before this boot replaced its predecessor; retained only until publication completes. */
|
|
467
|
+
previousEnvOutput?: { path: string; sha256: string };
|
|
466
468
|
pidsFile: string;
|
|
467
469
|
actors?: Record<string, unknown>;
|
|
468
470
|
fixtures?: Record<string, unknown>;
|
package/src/served-world.ts
CHANGED
|
@@ -1133,6 +1133,7 @@ export class WorldDoors {
|
|
|
1133
1133
|
const template = this.host.sitesTemplate?.();
|
|
1134
1134
|
if (template) headers.set(TWIN_SITES_HEADER, template);
|
|
1135
1135
|
headers.set(TWIN_SITE_HEADER, pageHost);
|
|
1136
|
+
headers.set(TWIN_PREFIX_HEADER, '/');
|
|
1136
1137
|
headers.set('x-forwarded-host', url.host);
|
|
1137
1138
|
headers.set('x-forwarded-proto', url.protocol.slice(0, -1));
|
|
1138
1139
|
}
|
|
@@ -1294,6 +1295,9 @@ export class WorldDoors {
|
|
|
1294
1295
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
1295
1296
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
1296
1297
|
const link = this.link(vendor!);
|
|
1298
|
+
const screenMount = tail[0] === '_screen';
|
|
1299
|
+
// A declared screen mount is answered only by the twin, never forwarded to a real-system link.
|
|
1300
|
+
if (screenMount && link) return Response.json({ error: `${vendor} is linked to the vendor itself: no twin screen is mounted` }, { status: 404 });
|
|
1297
1301
|
// a link spends the World's sealed vendor credential: never from the content plane, where any twin's markup runs
|
|
1298
1302
|
if (link && this.onPages(url)) return Response.json({ error: `${vendor} is linked to the vendor itself: it is used through the World's own origin` }, { status: 404 });
|
|
1299
1303
|
if (link) {
|
|
@@ -1302,6 +1306,18 @@ export class WorldDoors {
|
|
|
1302
1306
|
}
|
|
1303
1307
|
const twin = this.host.twinFetch(vendor!);
|
|
1304
1308
|
if (!twin) return Response.json({ error: `no twin "${vendor}" in ${this.served}` }, { status: 404 });
|
|
1309
|
+
// The Board's screen mount keeps the hostname the workspace declared. Without it, a multi-lane vendor's
|
|
1310
|
+
// /redis console page is sent to its Redis data API. The mount is the World's word, checked against discovery;
|
|
1311
|
+
// it survives redirects and forms through the public prefix instead of trusting a browser's routing header.
|
|
1312
|
+
let screenHost: string | undefined;
|
|
1313
|
+
if (screenMount) {
|
|
1314
|
+
try { screenHost = decodeURIComponent(tail[1] ?? '').toLowerCase(); }
|
|
1315
|
+
catch { return Response.json({ error: 'invalid screen hostname' }, { status: 400 }); }
|
|
1316
|
+
if (!screenHost || !(await this.screensOf(vendor!)).some(screen => screen.host?.toLowerCase() === screenHost)) {
|
|
1317
|
+
return Response.json({ error: `no built workspace of ${vendor} declares that screen hostname` }, { status: 404 });
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const vendorPath = (screenMount ? tail.slice(2) : tail).join('/');
|
|
1305
1321
|
// THE READ SCOPE: the twin is told the request is read-only and refuses the caller's writes itself
|
|
1306
1322
|
// (a Slack read over POST passes, a post is refused); a twin that cannot enforce that gets reads only
|
|
1307
1323
|
const enforced = scope === 'read' && (await this.enforcesRead(vendor!, twin, url.origin));
|
|
@@ -1323,14 +1339,15 @@ export class WorldDoors {
|
|
|
1323
1339
|
const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
|
|
1324
1340
|
if (cookies.length) headers.set('cookie', cookies.join('; ')); else headers.delete('cookie');
|
|
1325
1341
|
// the twin is mounted under this World's path: live URLs it mints must carry it (twinPublicBase)
|
|
1326
|
-
headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}`);
|
|
1342
|
+
headers.set(TWIN_PREFIX_HEADER, `/${this.served}/${vendor}${screenHost ? `/_screen/${encodeURIComponent(screenHost)}` : ''}`);
|
|
1343
|
+
if (screenHost) headers.set('x-volter-twin-original-host', screenHost);
|
|
1327
1344
|
headers.set('x-forwarded-host', url.host); headers.set('x-forwarded-proto', url.protocol.replace(':', ''));
|
|
1328
1345
|
// where this World shows the hostnames its twins answer, for a page that links one (twinSiteUrl): under its own
|
|
1329
1346
|
// origin on this machine (siteOfHost); a World without one shows none
|
|
1330
1347
|
const sitesAt = this.host.sitesTemplate?.() ?? null;
|
|
1331
1348
|
if (sitesAt) headers.set(TWIN_SITES_HEADER, sitesAt); else headers.delete(TWIN_SITES_HEADER);
|
|
1332
1349
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
1333
|
-
const answer = await twin(new Request(`${url.origin}/${
|
|
1350
|
+
const answer = await twin(new Request(`${url.origin}/${vendorPath}${url.search}`, { method: request.method, headers, ...(body === undefined ? {} : { body }), redirect: 'manual' }));
|
|
1334
1351
|
// an upgrade the twin answered (a tunnel's control or visitor socket) goes back as it is
|
|
1335
1352
|
if (answer.status === 101) return answer;
|
|
1336
1353
|
// staleness is a fact on the wire: a twin with a root says when the vendor was last observed
|
|
@@ -1554,6 +1571,7 @@ export class WorldDoors {
|
|
|
1554
1571
|
return new Response(null, { status: 204, headers });
|
|
1555
1572
|
}
|
|
1556
1573
|
if (request.method === 'PUT') {
|
|
1574
|
+
if (request.headers.get('if-none-match') === '*' && worldBrowser(data, name)) return Response.json({ error: `browser ${name} already exists; choose it or use another name` }, { status: 412 });
|
|
1557
1575
|
if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return Response.json({ error: 'browser state is application/json' }, { status: 415 });
|
|
1558
1576
|
if (!worldBrowser(data, name) && worldBrowsers(data).length >= BROWSER_COUNT_LIMIT) return Response.json({ error: `a World holds at most ${BROWSER_COUNT_LIMIT} browsers; delete one before adding another` }, { status: 409 });
|
|
1559
1577
|
const oversized = (): Response => Response.json({ error: `browser state PUT body is limited to ${BROWSER_STATE_BYTES_LIMIT} bytes (1 MiB)` }, { status: 413 });
|
|
@@ -1992,7 +2010,10 @@ export class WorldDoors {
|
|
|
1992
2010
|
const origin = readBranchMeta(state, this.controlRoot(name))?.origin;
|
|
1993
2011
|
const identity = { mounted: mounted?.resolvedPackage ? { package: mounted.resolvedPackage, version: mounted.resolvedVersion ?? null } : null, pinned: mounted?.pinnedSource ?? null };
|
|
1994
2012
|
const dataOrigin = origin ? { kind: 'shared-history', url: origin.at } : refresh ? { kind: 'vendor-refresh', url: root?.url ?? null } : { kind: 'unrecorded', url: null };
|
|
1995
|
-
|
|
2013
|
+
const claimed = await this.claimedHosts(url.origin);
|
|
2014
|
+
const sites = (await this.browserSites(url.origin, claimed)).find(row => row.vendor === name)?.hosts ?? [];
|
|
2015
|
+
const readOnlySites = claimed.find(row => row.vendor === name)?.hosts ?? [];
|
|
2016
|
+
return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), sites, readOnlySites, resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
|
|
1996
2017
|
}
|
|
1997
2018
|
if (verb === 'refresh' && request.method === 'POST') {
|
|
1998
2019
|
const root = rootForControlRoot(this.controlRoot(name), name);
|
|
@@ -2270,9 +2291,14 @@ export class WorldDoors {
|
|
|
2270
2291
|
// a pack that lays out no board yet keeps its vendor's home on it (its root workspace, else its first unparameterized
|
|
2271
2292
|
// one), whose changes are all of the vendor's
|
|
2272
2293
|
const fallback = !listed.length;
|
|
2294
|
+
const screens = await this.screensOf(vendor);
|
|
2295
|
+
const screenPath = (path: string, hostname?: string): string => {
|
|
2296
|
+
const host = hostname?.toLowerCase();
|
|
2297
|
+
return host && !/[{<]/.test(host) && screens.some(screen => screen.host?.toLowerCase() === host)
|
|
2298
|
+
? `/_screen/${encodeURIComponent(host)}${path}` : path;
|
|
2299
|
+
};
|
|
2273
2300
|
let homeHost: string | undefined;
|
|
2274
2301
|
if (fallback) {
|
|
2275
|
-
const screens = await this.screensOf(vendor);
|
|
2276
2302
|
const home = screens.find((w) => w.path.replace(/\/+$/, '') === '') ?? screens.find((w) => !/[{<]/.test(w.path));
|
|
2277
2303
|
homeHost = home?.host;
|
|
2278
2304
|
if (home) listed = [{ id: `home:${home.id}`, kind: 'home', title: vendor, section: { id: 'home', title: 'Home' }, url: home.path, covers: [], drawn: true }];
|
|
@@ -2320,10 +2346,10 @@ export class WorldDoors {
|
|
|
2320
2346
|
const opened = siteAt(u.hostname.toLowerCase(), `${u.pathname}${u.search}`);
|
|
2321
2347
|
if (opened) at = { site: opened };
|
|
2322
2348
|
}
|
|
2323
|
-
else if (f.drawn) at = { screen: `${u.pathname}${u.search}
|
|
2349
|
+
else if (f.drawn) at = { screen: screenPath(`${u.pathname}${u.search}`, u.hostname) };
|
|
2324
2350
|
if (!at) unseen = claimed.get(vendor)?.has(u.hostname.toLowerCase()) ? 'sites-not-routed' : 'not-drawn';
|
|
2325
2351
|
} catch { if (fallback) at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}` }; else unseen = 'not-drawn'; }
|
|
2326
|
-
if (fallback) at = { screen: f.url.startsWith('/') ? f.url : `/${f.url}
|
|
2352
|
+
if (fallback) at = { screen: screenPath(f.url.startsWith('/') ? f.url : `/${f.url}`, homeHost) };
|
|
2327
2353
|
add(at, undefined, unseen);
|
|
2328
2354
|
}
|
|
2329
2355
|
}
|
package/src/up-cli.ts
CHANGED
|
@@ -40,7 +40,7 @@ export async function upCommand(subject: string | undefined, rest: string[], roo
|
|
|
40
40
|
process.stderr.write(`volter-world up ${instance.name}: app url recorded: ${appUrl}\n`);
|
|
41
41
|
}
|
|
42
42
|
if (rest.includes('--json')) process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
|
|
43
|
-
else printReady(instance);
|
|
43
|
+
else printReady(instance, !keepState);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
async function upInProcess(configId: string, options: Parameters<typeof upWorld>[1]): Promise<Awaited<ReturnType<typeof upWorld>>> {
|
|
@@ -52,7 +52,7 @@ async function upInProcess(configId: string, options: Parameters<typeof upWorld>
|
|
|
52
52
|
finally { process.off('SIGINT', cancel); process.off('SIGTERM', cancel); }
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function printReady(instance: Awaited<ReturnType<typeof upWorld
|
|
55
|
+
export function printReady(instance: Awaited<ReturnType<typeof upWorld>>, fresh = false): void {
|
|
56
56
|
process.stdout.write(`World ${instance.name} is ready (${instance.config})
|
|
57
57
|
Instance: ${instance.instance}
|
|
58
58
|
Created: ${instance.createdAt}
|
|
@@ -60,8 +60,8 @@ Created: ${instance.createdAt}
|
|
|
60
60
|
Services:
|
|
61
61
|
${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
${fresh ? 'This is a fresh instance. Set the clock and run your seed before the application.'
|
|
64
|
+
: 'This World retains its branch state. Continue with the application; do not seed its records again.'}
|
|
65
65
|
|
|
66
66
|
The two moves: if the twin stores it, create it through the vendor's own API (seed as the
|
|
67
67
|
person, move the clock first for history). Everything else — judgment, lookups, faults — is
|