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.
@@ -12,6 +12,8 @@ import {
12
12
  } from 'node:fs/promises';
13
13
  import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
+ import {appRelativeRoot,resolveAppRoot,rootAppNavigation} from '../app-layout.mjs';
16
+ import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
15
17
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
16
18
  import {
17
19
  applyPwaEntryReferences,
@@ -136,12 +138,13 @@ function isGlobLike(value){
136
138
  return /[*?\[\]{}]/u.test(value);
137
139
  }
138
140
 
139
- function validatePathList(value,label,{required=false}={}){
141
+ function validatePathList(value,label,{required=false,allowRoot=false}={}){
140
142
  if(!is.array(value)||(required&&value.length===0)){
141
143
  fail(`${label} must be ${required?'a non-empty':'an'} array of literal relative paths.`);
142
144
  }
143
145
  const normalized=value.map((entry,index)=>{
144
- const item=normalizeRelativePath(entry,`${label}[${index}]`);
146
+ const item=allowRoot?normalizeRelativeRoot(entry,`${label}[${index}]`)
147
+ :normalizeRelativePath(entry,`${label}[${index}]`);
145
148
  if(isGlobLike(item))fail(`${label}[${index}] must be literal; directories include descendants.`);
146
149
  return item;
147
150
  });
@@ -151,7 +154,8 @@ function validatePathList(value,label,{required=false}={}){
151
154
  if(required){
152
155
  for(let left=0;left<normalized.length;left+=1){
153
156
  for(let right=left+1;right<normalized.length;right+=1){
154
- if(sameOrDescendant(normalized[left],normalized[right])
157
+ if(normalized[left]==='.'||normalized[right]==='.'
158
+ ||sameOrDescendant(normalized[left],normalized[right])
155
159
  ||sameOrDescendant(normalized[right],normalized[left])){
156
160
  fail(`${label} has overlapping paths: ${normalized[left]} and ${normalized[right]}`);
157
161
  }
@@ -256,7 +260,7 @@ function validateSharedRoute(route,label){
256
260
  assertOnlyKeys(route,new Set(['source','destination','include','exclude']),label);
257
261
  const source=normalizeRelativeRoot(route.source,`${label}.source`);
258
262
  const destination=normalizeRelativeRoot(route.destination,`${label}.destination`);
259
- const include=validatePathList(route.include,`${label}.include`,{required:true});
263
+ const include=validatePathList(route.include,`${label}.include`,{required:true,allowRoot:true});
260
264
  const exclude=validatePathList(route.exclude??[],`${label}.exclude`);
261
265
  if(source==='.'||source==='apps'||source.startsWith('apps/')
262
266
  ||source==='dist'||source.startsWith('dist/')||source==='node_modules'
@@ -273,8 +277,8 @@ function validateSharedRoute(route,label){
273
277
  export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
274
278
  assertOnlyKeys(value,new Set(['schemaVersion','appsRoot','distRoot','sharedPayloads']),ROOT_CONFIG_NAME);
275
279
  if(value.schemaVersion!==1)fail(`${ROOT_CONFIG_NAME}.schemaVersion must be 1.`);
276
- if(value.appsRoot!=='apps'||value.distRoot!=='dist'){
277
- fail(`${ROOT_CONFIG_NAME} must bind appsRoot to "apps" and distRoot to "dist".`);
280
+ if(!['apps','.'].includes(value.appsRoot)||value.distRoot!=='dist'){
281
+ fail(`${ROOT_CONFIG_NAME} must bind appsRoot to "apps" or "." and distRoot to "dist".`);
278
282
  }
279
283
  if(!isPlainObject(value.sharedPayloads)){
280
284
  fail(`${ROOT_CONFIG_NAME}.sharedPayloads must be an object.`);
@@ -289,7 +293,7 @@ export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
289
293
  validateSharedRoute(route,`sharedPayloads.${id}[${index}]`)
290
294
  );
291
295
  }
292
- return {schemaVersion:1,appsRoot:'apps',distRoot:'dist',sharedPayloads,configPath};
296
+ return {schemaVersion:1,appsRoot:value.appsRoot,distRoot:'dist',sharedPayloads,configPath};
293
297
  }
294
298
 
295
299
  function normalizeOptionalRecord(value,label){
@@ -298,14 +302,14 @@ function normalizeOptionalRecord(value,label){
298
302
  return copyJson(value);
299
303
  }
300
304
 
301
- export function validateAppConfig(value,appId,rootConfig,configPath=`apps/${appId}/${APP_CONFIG_NAME}`){
305
+ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
302
306
  assertOnlyKeys(value,new Set([
303
307
  'schemaVersion','id','displayName','version','entry','strategy','security',
304
308
  'localAIModelPolicy','include','exclude','shared','adapter','pwa'
305
309
  ]),`${appId}/${APP_CONFIG_NAME}`);
306
310
  if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
307
- if(value.id!==appId||!APP_ID_PATTERN.test(value.id)){
308
- fail(`${appId}/${APP_CONFIG_NAME}.id must exactly match its apps directory.`);
311
+ if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
312
+ fail(`${APP_CONFIG_NAME}.id must be a valid application id matching the selected application: ${String(appId)}.`);
309
313
  }
310
314
  const displayName=assertPresentationText(value.displayName,`${appId}/${APP_CONFIG_NAME}.displayName`);
311
315
  parseSemver(value.version);
@@ -404,8 +408,8 @@ async function loadContext(requestedWorkspaceRoot,appId){
404
408
  const rootConfig=validateRootConfig(await readJson(rootConfigPath,ROOT_CONFIG_NAME),rootConfigPath);
405
409
  if(!is.string(appId)||!APP_ID_PATTERN.test(appId))fail(`Unsafe app id: ${String(appId)}`);
406
410
  const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
407
- const appRoot=resolveInside(appsRoot,appId,'app id');
408
- await assertContainedRealPath(appsRoot,appRoot,`apps/${appId}`);
411
+ const appRoot=resolveAppRoot(workspaceRoot,rootConfig,appId);
412
+ await assertContainedRealPath(appsRoot,appRoot,appId);
409
413
  const configPath=path.join(appRoot,APP_CONFIG_NAME);
410
414
  const config=validateAppConfig(await readJson(configPath,`${appId}/${APP_CONFIG_NAME}`),appId,rootConfig,configPath);
411
415
  return {
@@ -421,11 +425,12 @@ async function loadContext(requestedWorkspaceRoot,appId){
421
425
  }
422
426
 
423
427
  function destinationJoin(root,relative){
424
- return root==='.'?relative:`${root}/${relative}`;
428
+ return relative==='.'?root:root==='.'?relative:`${root}/${relative}`;
425
429
  }
426
430
 
427
431
  function appPackagePath(context, relative) {
428
- return `apps/${context.appId}/${relative}`;
432
+ const root=appRelativeRoot(context.rootConfig,context.appId);
433
+ return root?`${root}/${relative}`:relative;
429
434
  }
430
435
 
431
436
  function packageResourceUrl(relative) {
@@ -442,12 +447,13 @@ async function collectSelectedPath({
442
447
  records,
443
448
  destinations,
444
449
  signal,
445
- label
450
+ label,
451
+ allowRoot=false
446
452
  }){
447
453
  throwIfAborted(signal);
448
454
  if(isExcluded(selected,excludes))return;
449
455
  if(reject(selected))fail(`${label} selects a reserved private or generated path: ${selected}.`);
450
- const absolute=resolveInside(sourceRoot,selected,label);
456
+ const absolute=resolveInside(sourceRoot,selected,label,{allowRoot});
451
457
  let info;
452
458
  try{info=await lstat(absolute);}
453
459
  catch(error){
@@ -455,15 +461,16 @@ async function collectSelectedPath({
455
461
  throw error;
456
462
  }
457
463
  if(info.isSymbolicLink())fail(`${label} contains a symbolic link or junction: ${selected}.`);
464
+ if(selected==='.'&&!info.isDirectory())fail(`${label} root selection must be a directory.`);
458
465
  if(info.isDirectory()){
459
466
  const entries=await readdir(absolute,{withFileTypes:true});
460
467
  entries.sort((left,right)=>compareText(left.name,right.name));
461
468
  for(const entry of entries){
462
- const child=`${selected}/${entry.name}`;
469
+ const child=destinationJoin(selected,entry.name);
463
470
  await collectSelectedPath({
464
471
  sourceRoot,
465
472
  selected:child,
466
- destination:`${destination}/${entry.name}`,
473
+ destination:destinationJoin(destination,entry.name),
467
474
  excludes,
468
475
  reject,
469
476
  records,
@@ -498,9 +505,13 @@ async function collectPackageRecords(context,{signal}={}){
498
505
  records,
499
506
  destinations,
500
507
  signal,
501
- label:`apps/${context.appId}`
508
+ label:context.appId
502
509
  });
503
510
  }
511
+ // App ownership comes from its selected files, including in a root layout.
512
+ for(const record of records){
513
+ record.appRelativePath=path.relative(context.appRoot,record.source).split(path.sep).join('/');
514
+ }
504
515
  for(const sharedId of context.config.shared){
505
516
  for(const route of context.rootConfig.sharedPayloads[sharedId]){
506
517
  const sourceRoot=resolveInside(context.workspaceRoot,route.source,`sharedPayloads.${sharedId}.source`);
@@ -515,7 +526,8 @@ async function collectPackageRecords(context,{signal}={}){
515
526
  records,
516
527
  destinations,
517
528
  signal,
518
- label:`sharedPayloads.${sharedId}`
529
+ label:`sharedPayloads.${sharedId}`,
530
+ allowRoot:true
519
531
  });
520
532
  }
521
533
  }
@@ -527,19 +539,19 @@ async function collectPackageRecords(context,{signal}={}){
527
539
  return records;
528
540
  }
529
541
 
530
- async function browserDocuments(records,entry,appPrefix){
542
+ async function browserDocuments(records,entry){
531
543
  let entryDocument=null;
532
544
  const documents=[];
533
545
  for(const record of records){
546
+ if(record.appRelativePath===undefined)continue;
534
547
  const extension=path.posix.extname(record.destination).toLocaleLowerCase('en-US');
535
548
  if(extension!=='.html'&&extension!=='.htm')continue;
536
- const documentPath=record.destination.startsWith(appPrefix)
537
- ?record.destination.slice(appPrefix.length):record.destination;
549
+ const documentPath=record.appRelativePath;
538
550
  const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{
539
551
  documentPath
540
552
  });
541
553
  const document={path:documentPath,packagePath:record.destination,...copyJson(inspected)};
542
- if(record.destination===`${appPrefix}${entry}`){
554
+ if(documentPath===entry){
543
555
  entryDocument=document;
544
556
  }else if(inspected.bases.length>0){
545
557
  documents.push(document);
@@ -562,6 +574,16 @@ async function optionalDescriptor(context){
562
574
 
563
575
  async function inspectContext(context,{signal}={}){
564
576
  const records=await collectPackageRecords(context,{signal});
577
+ const documents=await browserDocuments(records,context.config.entry);
578
+ const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
579
+ context.appId,context.config.entry,documents.map(document=>document.path)
580
+ ):[];
581
+ for(const redirect of navigation){
582
+ const selected=records.find(record=>pathKey(record.destination)===pathKey(redirect.path));
583
+ if(selected&&!(await readFile(selected.source,'utf8')).includes('<!-- Arcane root application navigation -->')){
584
+ fail(`Root application navigation would replace selected content: ${redirect.path}.`);
585
+ }
586
+ }
565
587
  return {
566
588
  appId:context.appId,
567
589
  displayName:context.config.displayName,
@@ -578,8 +600,8 @@ async function inspectContext(context,{signal}={}){
578
600
  }),
579
601
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
580
602
  descriptor:await optionalDescriptor(context),
581
- browserDocuments:await browserDocuments(records,context.config.entry,appPackagePath(context,'')),
582
- files:[...new Set(['index.html',...records.map(record=>record.destination)])].sort(compareText),
603
+ browserDocuments:documents,
604
+ files:[...new Set(['index.html',...records.map(record=>record.destination),...navigation.map(redirect=>redirect.path)])].sort(compareText),
583
605
  output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
584
606
  };
585
607
  }
@@ -590,6 +612,12 @@ export async function discoverApps({workspaceRoot:requestedWorkspaceRoot}={}){
590
612
  await readJson(path.join(workspaceRoot,ROOT_CONFIG_NAME),ROOT_CONFIG_NAME),
591
613
  path.join(workspaceRoot,ROOT_CONFIG_NAME)
592
614
  );
615
+ if(rootConfig.appsRoot==='.'){
616
+ const configPath=path.join(workspaceRoot,APP_CONFIG_NAME);
617
+ const value=await readJson(configPath,APP_CONFIG_NAME);
618
+ const config=validateAppConfig(value,value?.id,rootConfig,configPath);
619
+ return [config.id];
620
+ }
593
621
  const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
594
622
  const entries=await readdir(appsRoot,{withFileTypes:true});
595
623
  const apps=[];
@@ -729,9 +757,12 @@ async function replaceDirectory(stagingRoot,outputRoot){
729
757
  async function packageWithContext(context,options={}){
730
758
  const {signal,onEvent,browserPwa=true}=options;
731
759
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
732
- const appPath=`apps/${context.appId}`;
760
+ const appPath=appRelativeRoot(context.rootConfig,context.appId);
733
761
  const entryPath=appPackagePath(context,context.config.entry);
734
762
  const inspected=await inspectContext(context,{signal});
763
+ const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
764
+ context.appId,context.config.entry,inspected.browserDocuments.map(document=>document.path)
765
+ ):[];
735
766
  if(options.dryRun){
736
767
  return {
737
768
  appId:context.appId,
@@ -782,6 +813,18 @@ async function packageWithContext(context,options={}){
782
813
  }else{
783
814
  await copyBase();
784
815
  }
816
+ for(const redirect of navigation){
817
+ throwIfAborted(signal);
818
+ const filePath=path.join(stagingRoot,...redirect.path.split('/'));
819
+ try{
820
+ const current=await readFile(filePath,'utf8');
821
+ if(!current.includes('<!-- Arcane root application navigation -->')){
822
+ fail(`Root application navigation would replace package content: ${redirect.path}.`);
823
+ }
824
+ }catch(error){if(error.code!=='ENOENT')throw error;}
825
+ await mkdir(path.dirname(filePath),{recursive:true});
826
+ await writeFile(filePath,redirect.content,'utf8');
827
+ }
785
828
  const files=await listOutputFiles(stagingRoot,{signal});
786
829
  // Traverse actual browser resources after the adapter finishes. Files
787
830
  // included only as application documents retain their original content.
@@ -802,7 +845,7 @@ async function packageWithContext(context,options={}){
802
845
  const appDocument=context.config.include.some(function includesAppDocument(selected){
803
846
  return sameOrDescendant(document.path,selected);
804
847
  });
805
- if(appDocument&&document.managedMaps.length>0&&inventory.has(document.packagePath)){
848
+ if(appDocument&&(appPath===''||document.managedMaps.length>0)&&inventory.has(document.packagePath)){
806
849
  documentPaths.add(document.packagePath);
807
850
  }
808
851
  }
@@ -828,8 +871,9 @@ async function packageWithContext(context,options={}){
828
871
  const documentUrl=entryDocument?.bases[0]?.href
829
872
  ?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
830
873
  const pending=[{file:entryPath,documentUrl}];
874
+ const sharedFiles=new Set(records.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
831
875
  for(const file of files){
832
- if(/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file)
876
+ if((sharedFiles.has(file)||/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file))
833
877
  &&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
834
878
  if(pwaEnabled&&path.posix.basename(file)==='arcane.importmap.json'){
835
879
  pending.push({file,documentUrl});
@@ -901,6 +945,15 @@ async function packageWithContext(context,options={}){
901
945
  if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
902
946
  fail(`Package output is missing its entry file: ${context.config.entry}.`);
903
947
  }
948
+ const installed=pwaEnabled?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
949
+ const navigationAliases=navigation.length?{
950
+ './':packageResourceUrl(entryPath),
951
+ [`./apps/${context.appId}`]:packageResourceUrl(entryPath),
952
+ [`./apps/${context.appId}/`]:packageResourceUrl(entryPath),
953
+ ...Object.fromEntries(navigation.map(redirect=>[
954
+ packageResourceUrl(redirect.path),packageResourceUrl(redirect.target.slice(1))
955
+ ]))
956
+ }:undefined;
904
957
  const pwaArtifacts=pwaEnabled?createPwaArtifacts({
905
958
  app:{
906
959
  id:context.appId,
@@ -909,6 +962,8 @@ async function packageWithContext(context,options={}){
909
962
  entry:packageResourceUrl(entryPath)
910
963
  },
911
964
  appPath,
965
+ ...(installed?.direct?{runtimeBase:`.${installed.browserRuntimeBase}`} : {}),
966
+ ...(navigationAliases?{navigationAliases}:{}),
912
967
  sdkVersion:assetVersion,
913
968
  pwa:context.config.pwa,
914
969
  files,
@@ -394,11 +394,25 @@ function installPwaWorker(manifest, clientUrl) {
394
394
  );
395
395
  }
396
396
 
397
+ function navigationRedirect(url) {
398
+ const exact = navigationAliases.get(url);
399
+ if (exact) return {location: exact, resource: cacheUrl(exact)};
400
+ const source = new URL(url);
401
+ const query = source.search;
402
+ if (!query) return null;
403
+ source.search = '';
404
+ const selected = navigationAliases.get(source.href);
405
+ if (!selected) return null;
406
+ const destination = new URL(selected);
407
+ destination.search = destination.search ? `${destination.search}&${query.slice(1)}` : query;
408
+ return {location: destination.href, resource: cacheUrl(selected)};
409
+ }
410
+
397
411
  async function requestedResource(request, url) {
398
412
  await restored;
399
- const redirect = request.mode === 'navigate' ? navigationAliases.get(url) : null;
400
- if (redirect && cacheUrl(redirect) !== url && ownedUrls.has(cacheUrl(redirect))) {
401
- return {response: Response.redirect(redirect, 302), done: Promise.resolve(null)};
413
+ const redirect = request.mode === 'navigate' ? navigationRedirect(url) : null;
414
+ if (redirect && cacheUrl(redirect.location) !== url && ownedUrls.has(redirect.resource)) {
415
+ return {response: Response.redirect(redirect.location, 302), done: Promise.resolve(null)};
402
416
  }
403
417
  if (!ownedUrls.has(url)) {
404
418
  return {response: await fetch(request), done: Promise.resolve(null)};
@@ -422,7 +436,7 @@ function installPwaWorker(manifest, clientUrl) {
422
436
  return;
423
437
  }
424
438
  if (manifestRestored && !ownedUrls.has(url)
425
- && !(request.mode === 'navigate' && navigationAliases.has(url))) {
439
+ && !(request.mode === 'navigate' && navigationRedirect(url))) {
426
440
  return;
427
441
  }
428
442
  const resource = requestedResource(request, url);
package/src/pwa.mjs CHANGED
@@ -202,6 +202,7 @@ export function createPwaArtifacts(
202
202
  mode = 'release',
203
203
  runtimeBase = './arcane/sdk/',
204
204
  appBase,
205
+ installationId,
205
206
  appPath = '',
206
207
  navigationAliases,
207
208
  revision
@@ -218,7 +219,7 @@ export function createPwaArtifacts(
218
219
  // Relocating app files must not change an existing installed app's default identity.
219
220
  const installationBase = appBase ?? (mode === 'development' ? applicationBase : './');
220
221
  const manifest = {
221
- id: installationBase,
222
+ id: installationId ?? installationBase,
222
223
  name: app.displayName,
223
224
  short_name: app.displayName,
224
225
  start_url: manifestUrl(app.entry, appPath ? basePath : applicationBase),
package/src/scaffold.mjs CHANGED
@@ -8,6 +8,7 @@ import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
8
8
  import {SDK_NAME,SDK_VERSION,workspaceTemplate} from './templates/workspace-template.mjs';
9
9
  import {inspectWorkspaceProfile,resolveSdkPackageDeclaration} from './workspace.mjs';
10
10
  import {parseSemver} from './packager/core.mjs';
11
+ import {resolveAppRoot} from './app-layout.mjs';
11
12
 
12
13
  const is = new Is(false);
13
14
 
@@ -310,6 +311,7 @@ async function runGitInit(workspaceRoot,signal,onEvent){
310
311
  export async function createWorkspace({
311
312
  targetPath,
312
313
  appId,
314
+ appsRoot='apps',
313
315
  displayName,
314
316
  target='browser',
315
317
  initializeGit=false,
@@ -318,6 +320,7 @@ export async function createWorkspace({
318
320
  }){
319
321
  validateInputs(appId,displayName);
320
322
  validateScaffoldTarget(target);
323
+ if(!['apps','.'].includes(appsRoot))fail('appsRoot must be apps or .','ARCANE_USAGE');
321
324
  if(!is.string(targetPath)||!targetPath.trim())fail('targetPath is required.','ARCANE_USAGE');
322
325
  if(!is.boolean(initializeGit))fail('initializeGit must be a boolean.','ARCANE_USAGE');
323
326
  throwIfAborted(signal);
@@ -332,20 +335,21 @@ export async function createWorkspace({
332
335
  },async workspaceOperationLease=>{
333
336
  const template=workspaceTemplate({
334
337
  appId,
338
+ appsRoot,
335
339
  displayName,
336
340
  target,
337
341
  appIcon:await scaffoldIcon(target)
338
342
  });
339
343
  const writtenFiles=await writeMissingFiles(workspaceRoot,template.files,{signal,onEvent});
340
- const workspaceRuntime=await materializeWorkspaceRuntimeContent({
344
+ const workspaceRuntime=appsRoot==='.'?null:await materializeWorkspaceRuntimeContent({
341
345
  workspaceRoot,
342
346
  signal,
343
347
  onEvent
344
348
  });
345
- const importMap=await generateImportMap({
349
+ const importMap=appsRoot==='.'?{pending:true,reason:'sdk-install-required'}:await generateImportMap({
346
350
  workspaceRoot,
347
351
  appId,
348
- appRoot:path.join(workspaceRoot,'apps',appId),
352
+ appRoot:resolveAppRoot(workspaceRoot,{appsRoot},appId),
349
353
  workspaceOperationLease,
350
354
  signal,
351
355
  onEvent
@@ -354,6 +358,7 @@ export async function createWorkspace({
354
358
  const result={
355
359
  workspaceRoot,
356
360
  appId,
361
+ appsRoot,
357
362
  displayName:template.name,
358
363
  target,
359
364
  ...writtenFiles,
@@ -370,12 +375,13 @@ export async function createWorkspace({
370
375
  export async function initWorkspace({
371
376
  workspaceRoot=process.cwd(),
372
377
  appId,
378
+ appsRoot,
373
379
  displayName,
374
380
  target='browser',
375
381
  signal,
376
382
  onEvent
377
383
  }){
378
- validateInputs(appId,displayName);
384
+ if(appId!==undefined)validateInputs(appId,displayName);
379
385
  validateScaffoldTarget(target);
380
386
  throwIfAborted(signal);
381
387
  const resolvedRoot=path.resolve(workspaceRoot);
@@ -389,6 +395,28 @@ export async function initWorkspace({
389
395
  },async workspaceOperationLease=>{
390
396
  const profile=await existingWorkspaceProfile(resolvedRoot);
391
397
  const workspaceMode=profile?.workspaceMode??'external';
398
+ const selectedAppsRoot=appsRoot??profile?.config.appsRoot??'apps';
399
+ if(!['apps','.'].includes(selectedAppsRoot))fail('appsRoot must be apps or .','ARCANE_USAGE');
400
+ if(profile&&selectedAppsRoot!==profile.config.appsRoot){
401
+ fail('appsRoot must match the existing workspace layout; init does not relocate an application.','ARCANE_USAGE');
402
+ }
403
+ if(workspaceMode==='integrated'&&selectedAppsRoot==='.'){
404
+ fail('The integrated workspace keeps applications under apps; root scaffolding selects a standalone workspace.','ARCANE_USAGE');
405
+ }
406
+ if(selectedAppsRoot==='.'){
407
+ let existingId;
408
+ try{existingId=JSON.parse(await readFile(path.join(resolvedRoot,'arcane-package.json'),'utf8')).id;}
409
+ catch(error){if(error?.code!=='ENOENT')throw error;}
410
+ if(existingId!==undefined){
411
+ if(appId!==undefined&&appId!==existingId){
412
+ fail(`The root application id is ${existingId}; init does not replace its identity.`,'ARCANE_USAGE');
413
+ }
414
+ appId=existingId;
415
+ }
416
+ }
417
+ appId??=path.basename(resolvedRoot).normalize('NFKD').toLowerCase()
418
+ .replace(/[^a-z0-9]+/gu,'-').replace(/^-+|-+$/gu,'')||'arcane-app';
419
+ validateInputs(appId,displayName);
392
420
  const existingPackage=workspaceMode==='external'
393
421
  ?await readExistingPackage(resolvedRoot)
394
422
  :null;
@@ -415,6 +443,7 @@ export async function initWorkspace({
415
443
  })
416
444
  :workspaceTemplate({
417
445
  appId,
446
+ appsRoot:selectedAppsRoot,
418
447
  displayName,
419
448
  sdkDependencyName:sdkDeclaration.dependencyName,
420
449
  sdkDependencySpecifier:sdkDeclaration.specifier,
@@ -451,17 +480,23 @@ export async function initWorkspace({
451
480
  await emit(onEvent,{type:'scaffold.file.replaced',path:'arcane.lock.json'});
452
481
  }
453
482
  const packageUpdated=await applyPackageMerge(resolvedRoot,packagePlan,{signal,onEvent});
454
- const workspaceRuntime=workspaceMode==='external'
483
+ const directRuntime=workspaceMode==='external'&&selectedAppsRoot==='.';
484
+ const workspaceRuntime=workspaceMode==='external'&&!directRuntime
455
485
  ?await materializeWorkspaceRuntimeContent({
456
486
  workspaceRoot:resolvedRoot,
457
487
  signal,
458
488
  onEvent
459
489
  })
460
490
  :null;
461
- const importMap=await generateImportMap({
491
+ let sdkInstalled=true;
492
+ if(directRuntime){
493
+ try{await lstat(path.join(resolvedRoot,sdkDeclaration.packageSource,'package.json'));}
494
+ catch(error){if(error?.code==='ENOENT')sdkInstalled=false;else throw error;}
495
+ }
496
+ const importMap=!sdkInstalled?{pending:true,reason:'sdk-install-required'}:await generateImportMap({
462
497
  workspaceRoot:resolvedRoot,
463
498
  appId,
464
- appRoot:path.join(resolvedRoot,'apps',appId),
499
+ appRoot:resolveAppRoot(resolvedRoot,{appsRoot:selectedAppsRoot},appId),
465
500
  workspaceOperationLease,
466
501
  signal,
467
502
  onEvent
@@ -470,6 +505,7 @@ export async function initWorkspace({
470
505
  workspaceRoot:resolvedRoot,
471
506
  workspaceMode,
472
507
  appId,
508
+ appsRoot:selectedAppsRoot,
473
509
  displayName:template.name,
474
510
  target,
475
511
  ...writtenFiles,
@@ -0,0 +1,112 @@
1
+ import Is from 'strong-type';
2
+ import {readFile,readdir} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const is=new Is(false);
6
+
7
+ // Direct routes use the installed npm paths in both the browser and selected output.
8
+ export function installedSdkRoutes(packageSource,{security=false,direct=false}={}){
9
+ const routes=[
10
+ {
11
+ source:`${packageSource}/runtime/arcane`,destination:'arcane',
12
+ include:['components','css','entities','img','modules',...(security?['security']:[])],exclude:[]
13
+ },
14
+ {
15
+ source:`${packageSource}/browser-runtime`,destination:'arcane/sdk',
16
+ include:['.'],exclude:[]
17
+ },
18
+ {
19
+ source:`${packageSource}/runtime/strong-type`,destination:'arcane/dependencies/strong-type',
20
+ include:['.'],exclude:[]
21
+ },
22
+ {
23
+ source:packageSource,destination:'licenses/arcane-os',
24
+ include:['LICENSE','COMMERCIAL-LICENSE.md','NOTICE'],exclude:[]
25
+ }
26
+ ];
27
+ return direct?routes.map(route=>({...route,destination:route.source})):routes;
28
+ }
29
+
30
+ export function installedSdkPackageSource(config){
31
+ const routes=config?.sharedPayloads?.['browser-runtime'];
32
+ if(!is.array(routes)||routes.length!==4)return null;
33
+ const source=routes[3]?.source;
34
+ if(!is.string(source)||!/^node_modules\/(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/u.test(source))return null;
35
+ const expected=installedSdkRoutes(source,{
36
+ security:routes[0]?.include?.at(-1)==='security',
37
+ direct:routes[0]?.destination===routes[0]?.source
38
+ });
39
+ return routes.every(function matchesInstalledRoute(route,index){
40
+ const wanted=expected[index];
41
+ return route?.source===wanted.source&&route?.destination===wanted.destination
42
+ &&is.array(route.include)&&route.include.length===wanted.include.length
43
+ &&route.include.every(function matchesSelectedPath(value,item){return value===wanted.include[item];})
44
+ &&(route.exclude===undefined||(is.array(route.exclude)&&route.exclude.length===0));
45
+ })?source:null;
46
+ }
47
+
48
+ export async function readInstalledSdkLayout(workspaceRoot,config){
49
+ if(config===undefined){
50
+ try{config=JSON.parse(await readFile(path.join(workspaceRoot,'arcane-packager.json'),'utf8'));}
51
+ catch(error){if(error.code==='ENOENT')return null;throw error;}
52
+ }
53
+ const packageSource=installedSdkPackageSource(config);
54
+ if(packageSource===null)return null;
55
+ const packageRoot=path.join(workspaceRoot,...packageSource.split('/'));
56
+ const versionPath=path.join(packageRoot,'package.json');
57
+ const manifest=JSON.parse(await readFile(versionPath,'utf8'));
58
+ if(manifest.name!=='arcane-os'||!is.string(manifest.version)||!manifest.version){
59
+ const error=new Error('The installed SDK runtime must identify its arcane-os package version.');
60
+ error.code='ARCANE_WORKSPACE_INVALID';
61
+ throw error;
62
+ }
63
+ const routes=config.sharedPayloads['browser-runtime'];
64
+ return {
65
+ packageSource,packageRoot,versionPath,version:manifest.version,routes,
66
+ direct:routes[0].destination===routes[0].source,
67
+ runtimeBase:`/${routes[0].destination}/`,
68
+ browserRuntimeBase:`/${routes[1].destination}/`
69
+ };
70
+ }
71
+
72
+ export async function installedRuntimeFiles(workspaceRoot,layout,signal){
73
+ const files=[];
74
+ async function visit(directory,logical){
75
+ signal?.throwIfAborted();
76
+ const entries=await readdir(directory,{withFileTypes:true});
77
+ for(const entry of entries){
78
+ signal?.throwIfAborted();
79
+ const relative=logical?`${logical}/${entry.name}`:entry.name;
80
+ if(entry.isDirectory())await visit(path.join(directory,entry.name),relative);
81
+ else if(entry.isFile())files.push(relative);
82
+ }
83
+ }
84
+ for(const [index,route] of layout.routes.entries()){
85
+ if(index===3)continue;
86
+ const prefix=['','sdk','dependencies/strong-type'][index];
87
+ for(const selected of route.include){
88
+ const suffix=selected==='.'?'':selected;
89
+ await visit(
90
+ path.join(workspaceRoot,...route.source.split('/'),suffix),
91
+ [prefix,suffix].filter(Boolean).join('/')
92
+ );
93
+ }
94
+ }
95
+ return {files:files.sort()};
96
+ }
97
+
98
+ export function installedRuntimeTarget(relative,layout,{browser=false}={}){
99
+ const logicalRoots=['arcane','arcane/sdk','arcane/dependencies/strong-type','licenses/arcane-os'];
100
+ const routes=layout.routes.map((route,index)=>({...route,logical:logicalRoots[index]}));
101
+ for(const route of routes.sort(function longestDestinationFirst(left,right){
102
+ return right.logical.length-left.logical.length;
103
+ })){
104
+ if(!relative.startsWith(`${route.logical}/`))continue;
105
+ const suffix=relative.slice(route.logical.length+1);
106
+ if(route.include.some(function includesRuntimeTarget(selected){
107
+ const pathname=suffix.split(/[?#]/u)[0];
108
+ return selected==='.'||pathname===selected||pathname.startsWith(`${selected}/`);
109
+ }))return `${browser?route.destination:route.source}/${suffix}`;
110
+ }
111
+ return relative;
112
+ }