arcane-os 0.18.0 → 0.20.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.
package/src/mail.mjs CHANGED
@@ -3,6 +3,7 @@ import {ArcaneError,ERROR_CODES,throwIfAborted} from './errors.mjs';
3
3
  import {
4
4
  deleteMailCredential,
5
5
  getMailCredentialStatus,
6
+ mailCredentialLocation,
6
7
  readMailCredential,
7
8
  setMailCredential
8
9
  } from './mail-credentials.mjs';
@@ -54,13 +55,9 @@ function mailRecipientOptions(value,label){
54
55
  function mailCredentialOptions(options){
55
56
  return {
56
57
  profile:options.profile,
57
- platform:options.platform,
58
- systemRoot:options.systemRoot,
59
- temporaryDirectory:options.temporaryDirectory,
60
- spawnImpl:options.spawnImpl,
61
- runner:options.credentialRunner,
62
- signal:options.signal,
63
- timeoutMs:options.credentialTimeoutMs
58
+ cwd:options.cwd,
59
+ workspaceRoot:options.workspaceRoot,
60
+ signal:options.signal
64
61
  };
65
62
  }
66
63
 
@@ -92,24 +89,11 @@ async function deleteMailCredentialProfile(options){
92
89
 
93
90
  async function sendMailFromReport(options){
94
91
  const readReport=resolveMailCommandDependency(options,'readReport',null);
95
- const readCredential=resolveMailCommandDependency(options,'readCredential',readMailCredential);
96
92
  const send=resolveMailCommandDependency(options,'sendMail',sendResendMail);
97
93
  throwIfAborted(options.signal);
98
94
  const report=await readReport();
99
95
  throwIfAborted(options.signal);
100
- let apiKey=await readCredential(mailCredentialOptions(options));
101
- if(apiKey===null){
102
- throw new ArcaneError(
103
- ERROR_CODES.prerequisiteMissing,
104
- `No Resend credential is configured for profile ${String(options.profile)}.`
105
- );
106
- }
107
- if(!is.string(apiKey)||!apiKey){
108
- throw new ArcaneError(
109
- ERROR_CODES.operationFailed,
110
- 'The configured Resend credential could not be read.'
111
- );
112
- }
96
+ let apiKey=await readMailProviderKey(options);
113
97
  try{
114
98
  throwIfAborted(options.signal);
115
99
  const result=await send({
@@ -141,15 +125,15 @@ async function sendMailFromReport(options){
141
125
  }
142
126
  }
143
127
 
144
- async function serveMailGateway(options){
128
+ async function readMailProviderKey(options){
145
129
  const readCredential=resolveMailCommandDependency(options,'readCredential',readMailCredential);
146
- const startServer=resolveMailCommandDependency(options,'startServer',startResendMailServer);
147
- throwIfAborted(options.signal);
148
- let apiKey=await readCredential(mailCredentialOptions(options));
130
+ const credentialOptions=mailCredentialOptions(options);
131
+ const apiKey=await readCredential(credentialOptions);
149
132
  if(apiKey===null){
133
+ const location=mailCredentialLocation(credentialOptions);
150
134
  throw new ArcaneError(
151
135
  ERROR_CODES.prerequisiteMissing,
152
- `No Resend credential is configured for profile ${String(options.profile)}.`
136
+ `Missing ${location.setting} in ${location.filePath}.`
153
137
  );
154
138
  }
155
139
  if(!is.string(apiKey)||!apiKey){
@@ -158,6 +142,13 @@ async function serveMailGateway(options){
158
142
  'The configured Resend credential could not be read.'
159
143
  );
160
144
  }
145
+ return apiKey;
146
+ }
147
+
148
+ async function serveMailGateway(options){
149
+ const startServer=resolveMailCommandDependency(options,'startServer',startResendMailServer);
150
+ throwIfAborted(options.signal);
151
+ let apiKey=await readMailProviderKey(options);
161
152
  try{
162
153
  throwIfAborted(options.signal);
163
154
  const recipientAllowlist=mailRecipientOptions(options.allowTo,'allowTo');
@@ -424,6 +424,15 @@ function destinationJoin(root,relative){
424
424
  return root==='.'?relative:`${root}/${relative}`;
425
425
  }
426
426
 
427
+ function appPackagePath(context, relative) {
428
+ return `apps/${context.appId}/${relative}`;
429
+ }
430
+
431
+ function packageResourceUrl(relative) {
432
+ const segments = relative.split('/');
433
+ return `./${segments.map(encodeURIComponent).join('/')}`;
434
+ }
435
+
427
436
  async function collectSelectedPath({
428
437
  sourceRoot,
429
438
  selected,
@@ -483,7 +492,7 @@ async function collectPackageRecords(context,{signal}={}){
483
492
  await collectSelectedPath({
484
493
  sourceRoot:context.appRoot,
485
494
  selected,
486
- destination:selected,
495
+ destination:appPackagePath(context, selected),
487
496
  excludes:context.config.exclude,
488
497
  reject:isAppSourceForbidden,
489
498
  records,
@@ -512,23 +521,25 @@ async function collectPackageRecords(context,{signal}={}){
512
521
  }
513
522
  }
514
523
  records.sort((left,right)=>compareText(left.destination,right.destination));
515
- if(!records.some(record=>pathKey(record.destination)===pathKey(context.config.entry))){
524
+ if(!records.some(record=>pathKey(record.destination)===pathKey(appPackagePath(context, context.config.entry)))){
516
525
  fail(`Package entry is missing from the selected files: ${context.config.entry}.`);
517
526
  }
518
527
  return records;
519
528
  }
520
529
 
521
- async function browserDocuments(records,entry){
530
+ async function browserDocuments(records,entry,appPrefix){
522
531
  let entryDocument=null;
523
532
  const documents=[];
524
533
  for(const record of records){
525
534
  const extension=path.posix.extname(record.destination).toLocaleLowerCase('en-US');
526
535
  if(extension!=='.html'&&extension!=='.htm')continue;
536
+ const documentPath=record.destination.startsWith(appPrefix)
537
+ ?record.destination.slice(appPrefix.length):record.destination;
527
538
  const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{
528
- documentPath:record.destination
539
+ documentPath
529
540
  });
530
- const document={path:record.destination,...copyJson(inspected)};
531
- if(record.destination===entry){
541
+ const document={path:documentPath,packagePath:record.destination,...copyJson(inspected)};
542
+ if(record.destination===`${appPrefix}${entry}`){
532
543
  entryDocument=document;
533
544
  }else if(inspected.bases.length>0){
534
545
  documents.push(document);
@@ -567,8 +578,8 @@ async function inspectContext(context,{signal}={}){
567
578
  }),
568
579
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
569
580
  descriptor:await optionalDescriptor(context),
570
- browserDocuments:await browserDocuments(records,context.config.entry),
571
- files:records.map(record=>record.destination),
581
+ browserDocuments:await browserDocuments(records,context.config.entry,appPackagePath(context,'')),
582
+ files:[...new Set(['index.html',...records.map(record=>record.destination)])].sort(compareText),
572
583
  output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
573
584
  };
574
585
  }
@@ -611,6 +622,28 @@ async function copyRecords(records,stagingRoot,{signal,onEvent}={}){
611
622
  }
612
623
  }
613
624
 
625
+ async function writePackageLauncher(context, stagingRoot) {
626
+ function escapeHtml(value) {
627
+ return value.replaceAll('&', '&').replaceAll('"', '"')
628
+ .replaceAll('<', '&lt;').replaceAll('>', '&gt;');
629
+ }
630
+ const start = escapeHtml(packageResourceUrl(appPackagePath(context, context.config.entry)));
631
+ const title = escapeHtml(context.config.displayName);
632
+ const content = `<!doctype html>
633
+ <html lang="en">
634
+ <head>
635
+ <meta charset="utf-8">
636
+ <meta http-equiv="refresh" content="0; url=${start}">
637
+ <title>${title}</title>
638
+ </head>
639
+ <body>
640
+ <a href="${start}">Open ${title}</a>
641
+ </body>
642
+ </html>
643
+ `;
644
+ await writeFile(path.join(stagingRoot, 'index.html'), content, 'utf8');
645
+ }
646
+
614
647
  async function listOutputFiles(root,{signal}={}){
615
648
  const files=[];
616
649
  async function visit(directory,relativeRoot=''){
@@ -652,6 +685,7 @@ function releaseManifest(context,files,pwaArtifacts){
652
685
  displayName:context.config.displayName,
653
686
  version:context.config.version,
654
687
  entry:context.config.entry,
688
+ start:packageResourceUrl(appPackagePath(context, context.config.entry)),
655
689
  strategy:context.config.strategy,
656
690
  shared:[...context.config.shared],
657
691
  ...(pwaArtifacts?{pwa:{
@@ -695,6 +729,8 @@ async function replaceDirectory(stagingRoot,outputRoot){
695
729
  async function packageWithContext(context,options={}){
696
730
  const {signal,onEvent,browserPwa=true}=options;
697
731
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
732
+ const appPath=`apps/${context.appId}`;
733
+ const entryPath=appPackagePath(context,context.config.entry);
698
734
  const inspected=await inspectContext(context,{signal});
699
735
  if(options.dryRun){
700
736
  return {
@@ -724,7 +760,14 @@ async function packageWithContext(context,options={}){
724
760
  let promoted=false;
725
761
  try{
726
762
  const records=await collectPackageRecords(context,{signal});
727
- const copyBase=()=>copyRecords(records,stagingRoot,{signal,onEvent});
763
+ async function copyBase() {
764
+ await copyRecords(records,stagingRoot,{signal,onEvent});
765
+ if (!records.some(function selectedRootIndex(record) {
766
+ return record.destination === 'index.html';
767
+ })) {
768
+ await writePackageLauncher(context, stagingRoot);
769
+ }
770
+ }
728
771
  const adapter=await loadAdapter(context);
729
772
  if(adapter){
730
773
  await adapter.buildArcanePackage({
@@ -745,21 +788,22 @@ async function packageWithContext(context,options={}){
745
788
  const assetVersion=await readWorkspaceAssetVersion(context.workspaceRoot);
746
789
  const version=pwaEnabled?null:assetVersion;
747
790
  const inventory=new Set(files);
748
- const offlineInventory=pwaEnabled?new Set(selectPwaFiles(files,context.config.pwa)):null;
791
+ const offlineInventory=pwaEnabled?new Set(selectPwaFiles(files,context.config.pwa,appPath)):null;
749
792
  const offlineReferences=new Set();
750
- const entryUrl=new URL(context.config.entry,'http://arcane.invalid/');
793
+ const entryUrl=new URL(packageResourceUrl(entryPath),'http://arcane.invalid/');
751
794
  const pwaDocumentSources=new Map();
752
795
  if(pwaEnabled){
753
- const documentPaths=new Set([context.config.entry]);
796
+ const documentPaths=new Set([entryPath]);
754
797
  for(const selected of context.config.include){
755
- if(/\.html?$/iu.test(selected)&&inventory.has(selected))documentPaths.add(selected);
798
+ const selectedPath=appPackagePath(context,selected);
799
+ if(/\.html?$/iu.test(selected)&&inventory.has(selectedPath))documentPaths.add(selectedPath);
756
800
  }
757
801
  for(const document of inspected.browserDocuments){
758
802
  const appDocument=context.config.include.some(function includesAppDocument(selected){
759
803
  return sameOrDescendant(document.path,selected);
760
804
  });
761
- if(appDocument&&document.managedMaps.length>0&&inventory.has(document.path)){
762
- documentPaths.add(document.path);
805
+ if(appDocument&&document.managedMaps.length>0&&inventory.has(document.packagePath)){
806
+ documentPaths.add(document.packagePath);
763
807
  }
764
808
  }
765
809
  await Promise.all(
@@ -777,13 +821,13 @@ async function packageWithContext(context,options={}){
777
821
  )
778
822
  );
779
823
  }
780
- const entryDocument=pwaEnabled?pwaDocumentSources.get(context.config.entry).inspected
824
+ const entryDocument=pwaEnabled?pwaDocumentSources.get(entryPath).inspected
781
825
  :inspected.browserDocuments.find(function matchingEntryDocument(document){
782
826
  return document.path===context.config.entry;
783
827
  });
784
828
  const documentUrl=entryDocument?.bases[0]?.href
785
829
  ?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
786
- const pending=[{file:context.config.entry,documentUrl}];
830
+ const pending=[{file:entryPath,documentUrl}];
787
831
  for(const file of files){
788
832
  if(/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file)
789
833
  &&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
@@ -792,15 +836,15 @@ async function packageWithContext(context,options={}){
792
836
  }
793
837
  }
794
838
  for(const document of inspected.browserDocuments){
795
- if(document.managedMaps.length>0&&!pwaDocumentSources.has(document.path)){
796
- const url=new URL(document.path,entryUrl.origin);
797
- pending.push({file:document.path,documentUrl:document.bases[0]?.href
839
+ if(document.managedMaps.length>0&&!pwaDocumentSources.has(document.packagePath)){
840
+ const url=new URL(packageResourceUrl(document.packagePath),entryUrl.origin);
841
+ pending.push({file:document.packagePath,documentUrl:document.bases[0]?.href
798
842
  ?new URL(document.bases[0].href,url):url});
799
843
  }
800
844
  }
801
845
  for(const [file,document] of pwaDocumentSources){
802
- if(file===context.config.entry)continue;
803
- const url=new URL(file,entryUrl.origin);
846
+ if(file===entryPath)continue;
847
+ const url=new URL(packageResourceUrl(file),entryUrl.origin);
804
848
  pending.push({file,documentUrl:document.inspected.bases[0]?.href
805
849
  ?new URL(document.inspected.bases[0].href,url):url});
806
850
  }
@@ -832,7 +876,7 @@ async function packageWithContext(context,options={}){
832
876
  if(!pwaEnabled&&!traversable)continue;
833
877
  if(kind==='import'&&!/^(?:\.{1,2}\/|\/)/u.test(url))continue;
834
878
  try{
835
- const ownerUrl=new URL(relative,entryUrl.origin);
879
+ const ownerUrl=new URL(packageResourceUrl(relative),entryUrl.origin);
836
880
  const base=baseHref?new URL(baseHref,ownerUrl)
837
881
  :baseKind==='document'||path.posix.basename(relative)==='arcane.importmap.json'
838
882
  ?current.documentUrl:ownerUrl;
@@ -854,7 +898,7 @@ async function packageWithContext(context,options={}){
854
898
  if(files.some(file=>pathKey(file)===pathKey(RELEASE_MANIFEST_NAME))){
855
899
  fail(`Package content must not author ${RELEASE_MANIFEST_NAME}.`);
856
900
  }
857
- if(!files.some(file=>pathKey(file)===pathKey(context.config.entry))){
901
+ if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
858
902
  fail(`Package output is missing its entry file: ${context.config.entry}.`);
859
903
  }
860
904
  const pwaArtifacts=pwaEnabled?createPwaArtifacts({
@@ -862,8 +906,9 @@ async function packageWithContext(context,options={}){
862
906
  id:context.appId,
863
907
  displayName:context.config.displayName,
864
908
  version:context.config.version,
865
- entry:context.config.entry
909
+ entry:packageResourceUrl(entryPath)
866
910
  },
911
+ appPath,
867
912
  sdkVersion:assetVersion,
868
913
  pwa:context.config.pwa,
869
914
  files,
@@ -878,7 +923,7 @@ async function packageWithContext(context,options={}){
878
923
  files.push(artifact.path);
879
924
  }
880
925
  for(const [documentPath,document] of pwaDocumentSources){
881
- const documentUrl=new URL(documentPath,entryUrl.origin);
926
+ const documentUrl=new URL(packageResourceUrl(documentPath),entryUrl.origin);
882
927
  const outputBase=document.inspected.bases[0]?.href
883
928
  ?new URL(document.inspected.bases[0].href,documentUrl):documentUrl;
884
929
  const outputDirectory=new URL('./',outputBase).pathname;
package/src/pwa.mjs CHANGED
@@ -72,17 +72,20 @@ function selectedPath(file, selection) {
72
72
  return file === selection || file.startsWith(prefix);
73
73
  }
74
74
 
75
- export function selectPwaFiles(files, pwa) {
75
+ export function selectPwaFiles(files, pwa, appPath = '') {
76
76
  const config = normalizePwaConfig(pwa);
77
77
  const include = config?.offline.include ?? [];
78
78
  const exclude = config?.offline.exclude ?? [];
79
+ const appPrefix = appPath ? `${appPath}/` : '';
79
80
  return files.filter(
80
81
  function selectedOfflineFile(file) {
82
+ const relative = appPrefix && file.startsWith(appPrefix)
83
+ ? file.slice(appPrefix.length) : file;
81
84
  function includedPath(selection) {
82
- return selectedPath(file, selection);
85
+ return selectedPath(relative, selection);
83
86
  }
84
87
  function excludedPath(selection) {
85
- return selectedPath(file, selection);
88
+ return selectedPath(relative, selection);
86
89
  }
87
90
  return (include.length === 0 || include.some(includedPath))
88
91
  && !exclude.some(excludedPath);
@@ -199,6 +202,7 @@ export function createPwaArtifacts(
199
202
  mode = 'release',
200
203
  runtimeBase = './arcane/sdk/',
201
204
  appBase,
205
+ appPath = '',
202
206
  navigationAliases,
203
207
  revision
204
208
  } = {}
@@ -209,20 +213,19 @@ export function createPwaArtifacts(
209
213
  throw new TypeError('PWA mode must be release or development.');
210
214
  }
211
215
  const entryUrl = new URL(app.entry, 'https://arcane.invalid/');
212
- const applicationBase = appBase ?? (mode === 'development'
213
- ? new URL('./', entryUrl).pathname : './');
214
- const manifest = applicationManifest(
215
- {
216
- id: applicationBase,
217
- name: app.displayName,
218
- short_name: app.displayName,
219
- start_url: app.entry,
220
- scope: applicationBase,
221
- display: 'standalone',
222
- ...config.manifest
223
- },
224
- applicationBase
225
- );
216
+ const applicationBase = appBase ?? (appPath ? `./${appPath}/`
217
+ : mode === 'development' ? new URL('./', entryUrl).pathname : './');
218
+ // Relocating app files must not change an existing installed app's default identity.
219
+ const installationBase = appBase ?? (mode === 'development' ? applicationBase : './');
220
+ const manifest = {
221
+ id: installationBase,
222
+ name: app.displayName,
223
+ short_name: app.displayName,
224
+ start_url: manifestUrl(app.entry, appPath ? basePath : applicationBase),
225
+ scope: installationBase,
226
+ display: 'standalone',
227
+ ...applicationManifest(config.manifest, applicationBase)
228
+ };
226
229
  const generatedAssets = [
227
230
  PWA_MANIFEST_NAME,
228
231
  PWA_OFFLINE_MANIFEST_NAME,
@@ -232,7 +235,7 @@ export function createPwaArtifacts(
232
235
  return resourceUrl(basePath, file);
233
236
  }
234
237
  );
235
- const selectedFiles = selectPwaFiles(files, config);
238
+ const selectedFiles = selectPwaFiles(files, config, appPath);
236
239
  const selectedAssets = [
237
240
  ...selectedFiles.map(
238
241
  function packagedResource(file) {
@@ -132,6 +132,7 @@ appropriate.
132
132
  'dist/',
133
133
  'build/',
134
134
  '.arcane/',
135
+ '.env.json',
135
136
  '*.log',
136
137
  ''
137
138
  ].join('\n'));