arcane-os 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import Is from 'strong-type';
2
+ import {installedSdkRoutes} from '../sdk-runtime-layout.mjs';
2
3
  import {
3
4
  ARCANE_PROTOCOL,
4
5
  CLI_EVENT_PROTOCOL,
@@ -59,6 +60,7 @@ export function createWorkspaceLockDocument({
59
60
 
60
61
  export function workspaceTemplate({
61
62
  appId,
63
+ appsRoot='apps',
62
64
  displayName,
63
65
  sdkDependencyName=SDK_NAME,
64
66
  sdkDependencySpecifier=SDK_VERSION,
@@ -86,6 +88,12 @@ export function workspaceTemplate({
86
88
  if(!supportedTargets.includes(target)){
87
89
  throw new Error(`Unsupported scaffold target: ${String(target)}.`);
88
90
  }
91
+ if(!['apps','.'].includes(appsRoot))throw new Error('appsRoot must be apps or .');
92
+ if(appOnly&&appsRoot==='.')throw new Error('Root scaffolding selects a standalone workspace.');
93
+ const appPrefix=appsRoot==='.'?'':`apps/${appId}/`;
94
+ const directRuntime=appsRoot==='.'&&!appOnly;
95
+ const runtimePrefix=directRuntime?`./${sdkPackageSource}/runtime/arcane`:'./arcane';
96
+ const baseHref=appsRoot==='.'?'./':'../../';
89
97
  const native=target!=='browser';
90
98
  const buildTarget=native?target:'browser';
91
99
  const runTarget=native&&target!=='portable'?target:'browser';
@@ -125,7 +133,7 @@ appropriate.
125
133
  </script>
126
134
  `:'';
127
135
  const bootstrapMarkup=namedImports?'':
128
- ' <script type="module" src="./arcane/modules/ThemeBootstrap.js?v=1"></script>\n';
136
+ ` <script type="module" src="${runtimePrefix}/modules/ThemeBootstrap.js?v=1"></script>\n`;
129
137
  const files=new Map();
130
138
  files.set('.gitignore',[
131
139
  'node_modules/',
@@ -150,8 +158,8 @@ appropriate.
150
158
  files.set('AGENTS.md',`# ${name} development instructions
151
159
 
152
160
  - Use plain JavaScript, HTML, and CSS; do not introduce TypeScript or TSX.
153
- - Keep reusable portable mechanisms in the Arcane SDK and app-specific behavior under \`apps/${appId}/\`.
154
- - Keep \`arcane/css/theme.css\` before app styles and import \`arcane/ThemeBootstrap\` before app code runs.
161
+ - Keep reusable portable mechanisms in the Arcane SDK and app-specific behavior under \`${appPrefix||'./'}\`.
162
+ - Keep \`${runtimePrefix}/css/theme.css\` before app styles and import \`arcane/ThemeBootstrap\` before app code runs.
155
163
  - Use \`rgb(...)\` or \`rgba(...)\` for new CSS colors.
156
164
  - Build one named app and one explicit target at a time. Native targets may be unavailable until their adapters are installed.
157
165
  - Preserve complete application, model, document, message, log, diagnostic, process, and tool content. Do not truncate, clip, tail, elide, or silently discard it.
@@ -167,10 +175,11 @@ This repository contains the portable Arcane application \`${appId}\`. It includ
167
175
 
168
176
  \`\`\`sh
169
177
  npm install
178
+ npm run import-map
170
179
  npm run dev
171
180
  \`\`\`
172
181
 
173
- Open the loopback URL printed by the development server. The source server exposes this app and its physical \`arcane/\` runtime; it does not expose an Ollama HTTP endpoint.
182
+ Open the loopback URL printed by the development server. ${directRuntime?'This root app reads SDK files directly from its installed npm package; an ordinary static host uses the same resource paths.':'This app uses the existing physical arcane/ runtime layout.'} The SDK server does not expose an Ollama HTTP endpoint.
174
183
 
175
184
  Commit the generated \`package-lock.json\` after dependency installation. CI intentionally uses \`npm ci\` and therefore requires that lock. Before the SDK is published, install a locally packed \`${SDK_NAME}\` \`.tgz\` with \`npm install --save-dev --save-exact <path-to-tarball>\`; keep that tarball at the lock file's relative path for repeatable local \`npm ci\` runs.
176
185
 
@@ -185,13 +194,12 @@ npm run run
185
194
  \`\`\`
186
195
 
187
196
  The explicit \`import-map\` command refreshes
188
- \`apps/${appId}/modules/arcane.importmap.json\` and the managed inline browser
197
+ \`${appPrefix}modules/arcane.importmap.json\` and the managed inline browser
189
198
  import map in every directly navigable descriptor-admitted \`.html\`/\`.htm\`
190
199
  document. HTML component fragments remain package files but do not receive a
191
200
  document-level base or managed import map.
192
201
  Development, package, and build refresh that shared inventory when the selected operation needs it.
193
- Named \`arcane/*\` imports resolve against the physical workspace \`arcane/\`
194
- tree. Packaging copies the complete selected application, runtime, and specifier
202
+ Named \`arcane/*\` imports resolve through the managed map to the selected SDK files. Packaging copies the complete selected application, runtime, and specifier
195
203
  map to \`dist/${appId}\` without running application tests. Run \`verify\` only when
196
204
  the user explicitly selects verification or a release artifact that requires it;
197
205
  \`bundle\` creates the distributable archive and \`run\` launches the selected
@@ -201,7 +209,7 @@ Native targets are provider-supplied and must be scaffolded and selected
201
209
  explicitly; this browser workflow does not imply a standalone native executable.
202
210
  ${nativeGuide}
203
211
 
204
- Every browser release also carries Arcane OS licensing material under \`licenses/arcane-os/\`. Review those terms before distribution.
212
+ Every browser release also carries Arcane OS licensing material under \`${directRuntime?sdkPackageSource:'licenses/arcane-os'}\`. Review those terms before distribution.
205
213
  `);
206
214
  files.set('package.json',json({
207
215
  name:packageName,
@@ -230,10 +238,10 @@ Every browser release also carries Arcane OS licensing material under \`licenses
230
238
  }));
231
239
  files.set('arcane-packager.json',json({
232
240
  schemaVersion:1,
233
- appsRoot:'apps',
241
+ appsRoot,
234
242
  distRoot:'dist',
235
243
  sharedPayloads:{
236
- 'browser-runtime':[
244
+ 'browser-runtime':directRuntime?installedSdkRoutes(sdkPackageSource,{direct:true}):[
237
245
  {
238
246
  source:'arcane',
239
247
  destination:'arcane',
@@ -249,11 +257,11 @@ Every browser release also carries Arcane OS licensing material under \`licenses
249
257
  ]
250
258
  }
251
259
  }));
252
- files.set('arcane.lock.json',json(createWorkspaceLockDocument({
260
+ if(!directRuntime)files.set('arcane.lock.json',json(createWorkspaceLockDocument({
253
261
  dependencyName:sdkDependencyName,
254
262
  packageSource:sdkPackageSource
255
263
  })));
256
- files.set(`apps/${appId}/arcane-app.json`,json({
264
+ files.set(`${appPrefix}arcane-app.json`,json({
257
265
  schemaVersion:2,
258
266
  id:appId,
259
267
  displayName:name,
@@ -283,7 +291,7 @@ Every browser release also carries Arcane OS licensing material under \`licenses
283
291
  },
284
292
  targets:native?['browser',target].sort():['browser']
285
293
  }));
286
- files.set(`apps/${appId}/arcane-package.json`,json({
294
+ files.set(`${appPrefix}arcane-package.json`,json({
287
295
  schemaVersion:1,
288
296
  id:appId,
289
297
  displayName:name,
@@ -294,7 +302,7 @@ Every browser release also carries Arcane OS licensing material under \`licenses
294
302
  exclude:[],
295
303
  shared:['browser-runtime']
296
304
  }));
297
- files.set(`apps/${appId}/manifest.json`,json({
305
+ files.set(`${appPrefix}manifest.json`,json({
298
306
  name,
299
307
  short_name:titleCase(appId),
300
308
  start_url:'./index.html',
@@ -303,19 +311,19 @@ Every browser release also carries Arcane OS licensing material under \`licenses
303
311
  theme_color:'rgb(23, 34, 56)',
304
312
  icons:[]
305
313
  }));
306
- files.set(`apps/${appId}/index.html`,`<!doctype html>
314
+ files.set(`${appPrefix}index.html`,`<!doctype html>
307
315
  <html lang="en">
308
316
  <head>
309
317
  <meta charset="utf-8">
310
318
  <meta name="arcane-app-id" content="${html(appId)}">
311
- <base href="../../">
319
+ <base href="${baseHref}">
312
320
  ${importMapMarkup} <meta name="viewport" content="width=device-width, initial-scale=1">
313
321
  <meta name="theme-color" content="rgb(23, 34, 56)">
314
322
  <title>${html(name)}</title>
315
- <link rel="manifest" href="./apps/${html(appId)}/manifest.json">
316
- <link rel="stylesheet" href="./arcane/css/theme.css?v=1">
317
- <link rel="stylesheet" href="./arcane/css/primitives.css?v=1">
318
- <link rel="stylesheet" href="./apps/${html(appId)}/${html(appId)}.css?v=1">
323
+ <link rel="manifest" href="./${html(appPrefix)}manifest.json">
324
+ <link rel="stylesheet" href="${runtimePrefix}/css/theme.css?v=1">
325
+ <link rel="stylesheet" href="${runtimePrefix}/css/primitives.css?v=1">
326
+ <link rel="stylesheet" href="./${html(appPrefix)}${html(appId)}.css?v=1">
319
327
  ${bootstrapMarkup}</head>
320
328
  <body>
321
329
  <main class="app-shell">
@@ -332,11 +340,11 @@ ${bootstrapMarkup}</head>
332
340
  </div>
333
341
  </section>
334
342
  </main>
335
- <script type="module" src="./apps/${html(appId)}/modules/App.js?v=1"></script>
343
+ <script type="module" src="./${html(appPrefix)}modules/App.js?v=1"></script>
336
344
  </body>
337
345
  </html>
338
346
  `);
339
- files.set(`apps/${appId}/${appId}.css`,`body {
347
+ files.set(`${appPrefix}${appId}.css`,`body {
340
348
  margin: 0;
341
349
  min-height: 100vh;
342
350
  background: var(--background, rgb(13, 18, 32));
@@ -358,15 +366,15 @@ ${bootstrapMarkup}</head>
358
366
  }
359
367
  `);
360
368
  if(namedImports){
361
- files.set(`apps/${appId}/modules/arcane.importmap.json`,json({imports:{}}));
369
+ files.set(`${appPrefix}modules/arcane.importmap.json`,json({imports:{}}));
362
370
  }
363
371
  const themeSpecifier=namedImports
364
- ?'arcane/ThemeBootstrap':'../../../arcane/modules/ThemeBootstrap.js';
372
+ ?'arcane/ThemeBootstrap':`${appsRoot==='.'?'../':'../../../'}${runtimePrefix.slice(2)}/modules/ThemeBootstrap.js`;
365
373
  const appDataSpecifier=namedImports
366
- ?'arcane/AppDataScope':'../../../arcane/modules/AppDataScope.js';
374
+ ?'arcane/AppDataScope':`${appsRoot==='.'?'../':'../../../'}${runtimePrefix.slice(2)}/modules/AppDataScope.js`;
367
375
  const strongTypeSpecifier=namedImports
368
- ?'strong-type':'../../../arcane/dependencies/strong-type/index.js';
369
- files.set(`apps/${appId}/modules/App.js`,`import Is from '${strongTypeSpecifier}';
376
+ ?'strong-type':directRuntime?`../${sdkPackageSource}/runtime/strong-type/index.js`:'../../../arcane/dependencies/strong-type/index.js';
377
+ files.set(`${appPrefix}modules/App.js`,`import Is from '${strongTypeSpecifier}';
370
378
  import arcaneThemeReady from '${themeSpecifier}';
371
379
  import {
372
380
  resolveApplicationId,
@@ -407,9 +415,9 @@ action?.addEventListener('click',()=>{
407
415
  });
408
416
  `);
409
417
  if(native){
410
- files.set(`apps/${appId}/img/icon.png`,Buffer.from(appIcon));
418
+ files.set(`${appPrefix}img/icon.png`,Buffer.from(appIcon));
411
419
  }
412
- files.set(`apps/${appId}/test/app.test.mjs`,`import assert from 'node:assert/strict';
420
+ files.set(`${appPrefix}test/app.test.mjs`,`import assert from 'node:assert/strict';
413
421
  import {readFile} from 'node:fs/promises';
414
422
  import test from '${SDK_NAME}/testing';
415
423
 
@@ -420,13 +428,13 @@ test('application shell uses the shared Arcane theme in order',async()=>{
420
428
  readFile(new URL('index.html',appRoot),'utf8'),
421
429
  readFile(new URL('modules/App.js',appRoot),'utf8')
422
430
  ]);
423
- const theme=source.indexOf('./arcane/css/theme.css');
424
- const primitives=source.indexOf('./arcane/css/primitives.css');
425
- const appStyle=source.indexOf('./apps/${appId}/${appId}.css');
426
- const importMap=source.indexOf('${namedImports?'data-arcane-import-map':'./arcane/modules/ThemeBootstrap.js'}');
427
- const appModule=source.indexOf('./apps/${appId}/modules/App.js');
431
+ const theme=source.indexOf('${runtimePrefix}/css/theme.css');
432
+ const primitives=source.indexOf('${runtimePrefix}/css/primitives.css');
433
+ const appStyle=source.indexOf('./${appPrefix}${appId}.css');
434
+ const importMap=source.indexOf('${namedImports?'data-arcane-import-map':`${runtimePrefix}/modules/ThemeBootstrap.js`}');
435
+ const appModule=source.indexOf('./${appPrefix}modules/App.js');
428
436
 
429
- assert.match(source,/<base href="\\.\\.\\/\\.\\.\\/">/);
437
+ assert.ok(source.includes('<base href="${baseHref}">'));
430
438
  assert.match(source,/<meta name="arcane-app-id" content="${appId}">/);
431
439
  assert.ok(theme>=0&&primitives>theme&&appStyle>primitives);
432
440
  assert.ok(importMap>=0&&appModule>importMap);
@@ -445,7 +453,7 @@ test('application package identity matches its directory',async()=>{
445
453
  if(appOnly){
446
454
  return {
447
455
  name,
448
- files:new Map([...files].filter(([relative])=>relative.startsWith(`apps/${appId}/`)))
456
+ files:new Map([...files].filter(([relative])=>relative.startsWith(`${appPrefix}`)))
449
457
  };
450
458
  }
451
459
  return {name,files};
package/src/toolchain.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import Is from 'strong-type';
2
2
  import path from 'node:path';
3
- import {readdir,lstat,realpath} from 'node:fs/promises';
3
+ import {readdir,lstat,realpath,mkdir,readFile,writeFile} from 'node:fs/promises';
4
4
  import {createWorkspace,initWorkspace} from './scaffold.mjs';
5
5
  import {
6
6
  discoverApps as discoverWorkspaceApps,
@@ -11,7 +11,10 @@ import {
11
11
  } from './workspace.mjs';
12
12
  import {loadArcaneIntegratedProvider} from './integrated-provider-loader.mjs';
13
13
  import {startDevServer} from './dev-server.mjs';
14
- import {generateImportMap,readApplicationTestImportMapContext} from './import-map.mjs';
14
+ import {applyPwaEntryReferences,generateImportMap,readApplicationTestImportMapContext} from './import-map.mjs';
15
+ import {rootAppNavigation} from './app-layout.mjs';
16
+ import {createPwaArtifacts} from './pwa.mjs';
17
+ import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
15
18
  import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
16
19
  import {refreshAppPackageProjection} from './app-descriptor.mjs';
17
20
  import {
@@ -303,7 +306,7 @@ async function refreshPreparedImportMap(prepared,{signal,onEvent,workspaceOperat
303
306
  'The selected application package descriptor changed before import-map refresh.'
304
307
  );
305
308
  }
306
- return generateImportMap({
309
+ const importMap=await generateImportMap({
307
310
  workspaceRoot:prepared.workspaceRoot,
308
311
  appId:prepared.appId,
309
312
  appRoot:prepared.appRoot,
@@ -315,6 +318,75 @@ async function refreshPreparedImportMap(prepared,{signal,onEvent,workspaceOperat
315
318
  signal,
316
319
  onEvent
317
320
  });
321
+ if(prepared.validation.config.appsRoot==='.'){
322
+ await refreshRootApplicationFiles(prepared,inspected,importMap,{signal,onEvent});
323
+ }
324
+ return importMap;
325
+ }
326
+
327
+ async function refreshRootApplicationFiles(prepared,inspected,importMap,{signal,onEvent}){
328
+ const {workspaceRoot,appId}=prepared;
329
+ const manifest=prepared.validation.app.manifest;
330
+ const navigation=rootAppNavigation(appId,manifest.entry,inspected.browserDocuments.map(document=>document.path));
331
+ // These aliases belong to the SDK only after generation; retained app files stay authored.
332
+ for(const redirect of navigation){
333
+ throwIfAborted(signal);
334
+ try{
335
+ const current=await readFile(path.join(workspaceRoot,...redirect.path.split('/')),'utf8');
336
+ if(!current.includes('<!-- Arcane root application navigation -->')){
337
+ throw new ArcaneError(ERROR_CODES.workspaceInvalid,
338
+ `Root application navigation would replace authored content: ${redirect.path}.`);
339
+ }
340
+ }catch(error){if(error.code!=='ENOENT')throw error;}
341
+ }
342
+ const installed=await readInstalledSdkLayout(workspaceRoot,prepared.validation.config);
343
+ const entry=`/${manifest.entry.split('/').map(encodeURIComponent).join('/')}`;
344
+ const navigationAliases={
345
+ '/':entry,
346
+ [`/apps/${appId}`]:entry,
347
+ [`/apps/${appId}/`]:entry,
348
+ ...Object.fromEntries(navigation.map(redirect=>[`/${redirect.path}`,redirect.target]))
349
+ };
350
+ // The source host serves the installed files in place. There is no runtime projection.
351
+ const files=[...new Set([
352
+ ...inspected.files.filter(file=>file!=='index.html'||manifest.include.includes('index.html')),
353
+ importMap.artifactRelativePath,
354
+ ...navigation.map(redirect=>redirect.path)
355
+ ])];
356
+ const pwa=installed?.direct&&manifest.pwa?.enabled?createPwaArtifacts({
357
+ app:{id:appId,displayName:manifest.displayName,version:manifest.version,entry},
358
+ sdkVersion:installed.version,
359
+ pwa:manifest.pwa,
360
+ files,
361
+ basePath:'/',
362
+ appBase:'/',
363
+ installationId:`/apps/${appId}/`,
364
+ runtimeBase:installed.browserRuntimeBase,
365
+ mode:'development',
366
+ navigationAliases
367
+ }):null;
368
+ for(const file of [...navigation,...(pwa?.files??[])]){
369
+ throwIfAborted(signal);
370
+ const filePath=path.join(workspaceRoot,...file.path.split('/'));
371
+ await mkdir(path.dirname(filePath),{recursive:true});
372
+ await writeFile(filePath,file.content,'utf8');
373
+ }
374
+ if(pwa){
375
+ for(const document of inspected.browserDocuments){
376
+ throwIfAborted(signal);
377
+ const filePath=path.join(prepared.appRoot,...document.path.split('/'));
378
+ const content=await readFile(filePath,'utf8');
379
+ await writeFile(filePath,applyPwaEntryReferences(content,{
380
+ manifestUrl:`/${pwa.entryAssets.manifest}`,
381
+ bootstrapUrl:`/${pwa.entryAssets.bootstrap}`
382
+ }),'utf8');
383
+ }
384
+ }
385
+ await emit(onEvent,{
386
+ type:'import-map.root-files.completed',appId,
387
+ navigation:navigation.map(redirect=>redirect.path),
388
+ pwa:pwa?.files.map(file=>file.path)??[]
389
+ });
318
390
  }
319
391
 
320
392
  async function importMapApplication(options={}){
package/src/workspace.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import Is from 'strong-type';
2
+ import {appRelativeRoot, resolveAppRoot, appBaseHref} from './app-layout.mjs';
2
3
  import {readFile,readdir,realpath,stat} from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import {
@@ -9,6 +10,7 @@ import {
9
10
  import {loadAppDescriptor} from './app-descriptor.mjs';
10
11
  import {SDK_NAME as EXPECTED_SDK_NAME} from './constants.mjs';
11
12
  import {inspectImportMapHtml} from './import-map.mjs';
13
+ import {installedSdkPackageSource} from './sdk-runtime-layout.mjs';
12
14
 
13
15
  const is = new Is(false);
14
16
 
@@ -159,6 +161,13 @@ function classifyRootConfig(config){
159
161
  const validated=validatePackagerRootConfig(config,ROOT_CONFIG_NAME);
160
162
  const routes=validated.sharedPayloads['browser-runtime'];
161
163
  if(!is.array(routes))fail(`${ROOT_CONFIG_NAME} must define browser-runtime routes.`);
164
+ const installedPackageSource=installedSdkPackageSource(validated);
165
+ if(installedPackageSource!==null){
166
+ return {
167
+ ...validated,workspaceMode:'external',browserRuntimeLayout:'installed-v1',
168
+ sdkPackageSource:installedPackageSource
169
+ };
170
+ }
162
171
  const externalPackageSource=routes.length===2
163
172
  &&dependencyNameForSdkPackageSource(routes[1]?.source)!==null
164
173
  ?routes[1].source
@@ -220,6 +229,25 @@ async function discoverAppsInRoot(root,config,appId){
220
229
  if(appId!==undefined&&(!is.string(appId)||!APP_ID_PATTERN.test(appId))){
221
230
  fail(`Invalid app id: ${String(appId)}.`,'ARCANE_USAGE');
222
231
  }
232
+ async function readApp(appRoot,id,manifest){
233
+ const configPath=path.join(appRoot,APP_CONFIG_NAME);
234
+ manifest??=await readJson(configPath,`${appRelativeRoot(config,id) || '.'}/${APP_CONFIG_NAME}`);
235
+ const validatedManifest=validatePackagerAppConfig(manifest,id,config,configPath);
236
+ if(!validatedManifest.shared.includes('browser-runtime')){
237
+ fail(`${configPath} must include the browser-runtime shared payload.`);
238
+ }
239
+ const descriptor=await loadAppDescriptor({workspaceRoot:root,appRoot,appId:id,packageManifest:manifest});
240
+ return completeValue({
241
+ appId:id,appRoot,manifest:validatedManifest,
242
+ descriptor:descriptor.descriptor,descriptorSource:descriptor.source,
243
+ descriptorPath:descriptor.descriptorPath
244
+ });
245
+ }
246
+ if(config.appsRoot==='.'){
247
+ const manifest=await readJson(path.join(root,APP_CONFIG_NAME),APP_CONFIG_NAME);
248
+ if(appId!==undefined&&appId!==manifest.id)return [];
249
+ return [await readApp(root,manifest.id,manifest)];
250
+ }
223
251
  const appsRoot=path.join(root,'apps');
224
252
  await assertRealDirectory(appsRoot,'Workspace apps root');
225
253
  const entries=await readdir(appsRoot,{withFileTypes:true});
@@ -232,26 +260,7 @@ async function discoverAppsInRoot(root,config,appId){
232
260
  if(!entry.isDirectory())continue;
233
261
  const appRoot=path.join(appsRoot,entry.name);
234
262
  try{
235
- const configPath=path.join(appRoot,APP_CONFIG_NAME);
236
- const manifest=await readJson(configPath,`apps/${entry.name}/${APP_CONFIG_NAME}`);
237
- const validatedManifest=validatePackagerAppConfig(manifest,entry.name,config,configPath);
238
- if(!validatedManifest.shared.includes('browser-runtime')){
239
- fail(`apps/${entry.name}/${APP_CONFIG_NAME} must include the browser-runtime shared payload.`);
240
- }
241
- const descriptor=await loadAppDescriptor({
242
- workspaceRoot:root,
243
- appRoot,
244
- appId:entry.name,
245
- packageManifest:manifest
246
- });
247
- apps.push(completeValue({
248
- appId:entry.name,
249
- appRoot,
250
- manifest:validatedManifest,
251
- descriptor:descriptor.descriptor,
252
- descriptorSource:descriptor.source,
253
- descriptorPath:descriptor.descriptorPath
254
- }));
263
+ apps.push(await readApp(appRoot,entry.name));
255
264
  }catch(error){
256
265
  if(!String(error?.message).includes('does not exist'))throw error;
257
266
  }
@@ -286,7 +295,7 @@ export async function selectApp(workspaceRoot=process.cwd(),appId){
286
295
  if(!selected)fail(`Unknown app "${appId}".`);
287
296
  return selected;
288
297
  }
289
- if(apps.length===0)fail('No Arcane applications were found under apps/.');
298
+ if(apps.length===0)fail('No Arcane applications were found at the configured appsRoot.');
290
299
  if(apps.length>1){
291
300
  fail(`This workspace contains multiple apps; select one explicitly: ${apps.map(app=>app.appId).join(', ')}.`,'ARCANE_USAGE');
292
301
  }
@@ -305,7 +314,7 @@ export async function resolveWorkspace({workspaceRoot=process.cwd(),appId}={}){
305
314
  }else if(apps.length===1){
306
315
  [app]=apps;
307
316
  }else if(apps.length===0){
308
- fail('No Arcane applications were found under apps/.');
317
+ fail('No Arcane applications were found at the configured appsRoot.');
309
318
  }else{
310
319
  fail(`This workspace contains multiple apps; select one explicitly: ${apps.map(item=>item.appId).join(', ')}.`,'ARCANE_USAGE');
311
320
  }
@@ -355,39 +364,43 @@ export async function resolveInstalledSdkInstallation(workspaceRoot,declaration)
355
364
 
356
365
  function assertHtmlContract(source,appId,{
357
366
  entry='index.html',
367
+ appPath=`apps/${appId}`,
368
+ baseHref='../../',
369
+ runtimePath='arcane',
358
370
  strictStyles=true,
359
371
  allowMissingManagedImportMap=false
360
372
  }={}){
361
- const entryLabel=`apps/${appId}/${entry}`;
373
+ const entryLabel=[appPath,entry].filter(Boolean).join('/');
362
374
  const htmlContract=inspectImportMapHtml(source);
363
375
  const appIdMetadata=htmlContract.metas.filter(meta=>meta.name==='arcane-app-id');
364
376
  if(appIdMetadata.length!==1||appIdMetadata[0].content!==appId){
365
377
  fail(`${entryLabel} must declare exactly one active matching arcane-app-id metadata element.`);
366
378
  }
367
- if(htmlContract.bases.length!==1||htmlContract.bases[0].href!=='../../'){
368
- fail(`${entryLabel} must declare exactly one active <base href="../../">.`);
379
+ if(htmlContract.bases.length!==1||htmlContract.bases[0].href!==baseHref){
380
+ fail(`${entryLabel} must declare exactly one active <base href="${baseHref}">.`);
369
381
  }
370
- const resourcePath=value=>value.split(/[?#]/u,1)[0];
382
+ const resourcePath=value=>value.split(/[?#]/u,1)[0].replace(/^\.\//u,'/');
371
383
  const styles=htmlContract.links.filter(link=>link.rel
372
384
  .split(/[\t\n\f\r ]+/u).includes('stylesheet'));
373
385
  const positionOfStyle=expected=>styles.find(link=>resourcePath(link.href)===expected)?.start??-1;
374
- const theme=positionOfStyle('./arcane/css/theme.css');
375
- const primitives=positionOfStyle('./arcane/css/primitives.css');
376
- const escapedAppId=appId.replace(/[.*+?^${}()|[\]\\]/gu,'\\$&');
386
+ const theme=positionOfStyle(`/${runtimePath}/css/theme.css`);
387
+ const primitives=positionOfStyle(`/${runtimePath}/css/primitives.css`);
388
+ const appPrefix=appPath?`${appPath}/`:'';
389
+ const escapedAppPrefix=appPrefix.replace(/[.*+?^${}()|[\]\\]/gu,'\\$&');
377
390
  const appStyle=styles.find(link=>new RegExp(
378
- `^(?:\\./|/)apps/${escapedAppId}/[^/]+\\.css$`,
391
+ `^(?:\\./|/)${escapedAppPrefix}[^/]+\\.css$`,
379
392
  'u'
380
393
  ).test(resourcePath(link.href)))?.start??-1;
381
394
  const modules=htmlContract.scripts.filter(script=>script.type==='module'&&script.src);
382
395
  const bootstrap=modules.find(script=>resourcePath(script.src)
383
- ==='./arcane/modules/ThemeBootstrap.js')?.start??-1;
396
+ ===`/${runtimePath}/modules/ThemeBootstrap.js`)?.start??-1;
384
397
  if(htmlContract.managedMaps.length>1){
385
398
  fail(`${entryLabel} must contain at most one active managed Arcane import map.`);
386
399
  }
387
400
  const managedImportMap=htmlContract.managedMaps[0]?.start??-1;
388
401
  const firstModule=htmlContract.firstModulePosition;
389
402
  const appModule=modules.find(script=>new RegExp(
390
- `^(?:\\./|/)apps/${escapedAppId}/.+\\.(?:js|mjs)$`,
403
+ `^(?:\\./|/)${escapedAppPrefix}(?!arcane/|node_modules/).+\\.(?:js|mjs)$`,
391
404
  'u'
392
405
  ).test(resourcePath(script.src)))?.start??-1;
393
406
  if(theme<0){
@@ -436,12 +449,6 @@ export async function validateDiscoveredApplication({
436
449
  fail('A discovered Arcane application is required for focused validation.');
437
450
  }
438
451
  const canonicalWorkspaceRoot=await realpath(workspaceRoot);
439
- await assertRealDirectory(path.join(canonicalWorkspaceRoot,'apps'),'Workspace apps root');
440
- const expectedAppRoot=path.join(canonicalWorkspaceRoot,'apps',app.appId);
441
- if(!sameDirectoryPath(app.appRoot,expectedAppRoot)){
442
- fail(`Discovered app ${app.appId} does not belong to the selected workspace.`);
443
- }
444
- const canonicalAppRoot=await realpath(app.appRoot);
445
452
  let config=workspaceConfig;
446
453
  if(!config){
447
454
  const profile=await inspectWorkspaceProfile(canonicalWorkspaceRoot);
@@ -453,6 +460,12 @@ export async function validateDiscoveredApplication({
453
460
  if(!isObject(config?.sharedPayloads)){
454
461
  fail('The selected Arcane workspace configuration is unavailable for focused validation.');
455
462
  }
463
+ const expectedAppRoot=resolveAppRoot(canonicalWorkspaceRoot,config,app.appId);
464
+ await assertRealDirectory(expectedAppRoot,'Selected application root');
465
+ if(!sameDirectoryPath(app.appRoot,expectedAppRoot)){
466
+ fail(`Discovered app ${app.appId} does not belong to the selected workspace.`);
467
+ }
468
+ const canonicalAppRoot=await realpath(app.appRoot);
456
469
  const configPath=path.join(canonicalAppRoot,APP_CONFIG_NAME);
457
470
  const rawManifest=await readJson(
458
471
  configPath,
@@ -484,6 +497,9 @@ export async function validateDiscoveredApplication({
484
497
  }
485
498
  assertHtmlContract(await readFile(entryPath,'utf8'),app.appId,{
486
499
  entry:manifest.entry,
500
+ appPath:appRelativeRoot(config,app.appId),
501
+ baseHref:appBaseHref(canonicalWorkspaceRoot,canonicalAppRoot,manifest.entry),
502
+ runtimePath:config.sharedPayloads['browser-runtime'][0].destination,
487
503
  strictStyles:workspaceMode==='external',
488
504
  allowMissingManagedImportMap
489
505
  });