arcane-os 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4
4
+
5
+ - Allowed external and integrated physical SDK workspaces to omit the optional
6
+ `security` runtime route while preserving the canonical functional route
7
+ order, `dependencies` and `sdk` projections, external license routing, and
8
+ compatibility with workspaces that still include `security`.
9
+ - Preserved a newer microphone retry's press, status, and operation identity
10
+ when an earlier pending capture request settles, while retaining the original
11
+ operation correlation for successful transcription.
12
+
13
+ ## 0.3.3
14
+
15
+ - Corrected installed-workspace import-map refresh to preserve rich browser
16
+ document inspection records while passing only navigable document paths to
17
+ the public generator, including `.html` and `.htm` pages without modifying
18
+ HTML fragments.
19
+ - Restored one semantic `arcane.lock.json` contract across scaffold, init, and
20
+ installed runtime materialization, replacing stale versions with the exact
21
+ installed package version and npm-alias roots without byte or security
22
+ metadata.
23
+ - Added selected installed-package coverage for stale-lock replacement and the
24
+ public multi-document import-map command through the packed npm artifact.
25
+
3
26
  ## 0.3.2
4
27
 
5
28
  - Preserved complete application, document, model, message, event, process,
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.3.2` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.3.4` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -126,7 +126,7 @@ uses the same controller for automatic memory extraction.
126
126
  Create a new repository-shaped Arcane application with the exact stable SDK:
127
127
 
128
128
  ```bash
129
- npx arcane-os@0.3.2 new my-app --path ./my-app --target portable --git
129
+ npx arcane-os@0.3.4 new my-app --path ./my-app --target portable --git
130
130
  cd my-app
131
131
  npm install
132
132
  npm run check
@@ -137,7 +137,7 @@ To enroll an existing repository, install the exact SDK and initialize only
137
137
  missing Arcane files:
138
138
 
139
139
  ```bash
140
- npm install --save-dev --save-exact arcane-os@0.3.2
140
+ npm install --save-dev --save-exact arcane-os@0.3.4
141
141
  npm exec -- arcane init my-app --target portable
142
142
  ```
143
143
 
@@ -153,7 +153,7 @@ npm exec -- arcane-os targets
153
153
  No global SDK install or standalone Arcane CLI is required. The application
154
154
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
155
155
 
156
- Use `npx arcane-os@0.3.2` for the initial bootstrap because it names this npm
156
+ Use `npx arcane-os@0.3.4` for the initial bootstrap because it names this npm
157
157
  package explicitly; bare `npx arcane` outside an installed project could resolve
158
158
  a different package. Both installed commands invoke the same headless toolchain.
159
159
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -174,7 +174,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
174
174
 
175
175
  # From the generated app repository
176
176
  cd ../local-app
177
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.3.2.tgz
177
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.3.4.tgz
178
178
  npm run check
179
179
  npm ci
180
180
  ```
@@ -184,7 +184,7 @@ same location. The lockfile retains the selected package dependency while
184
184
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
185
185
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
186
186
  runner also needs that tarball at the locked path. After publication, replace
187
- the local declaration with the exact `arcane-os@0.3.2` registry package and
187
+ the local declaration with the exact `arcane-os@0.3.4` registry package and
188
188
  commit the regenerated lock.
189
189
 
190
190
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -322,7 +322,7 @@ package installation, or assertions.
322
322
 
323
323
  ## Current target support
324
324
 
325
- Version `0.3.2` exposes one browser target and five explicitly paired
325
+ Version `0.3.4` exposes one browser target and five explicitly paired
326
326
  native development targets: a non-runnable portable directory, a
327
327
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
328
328
  unsigned-local-test DEBs, and an Android development-signed APK. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -1235,11 +1235,14 @@
1235
1235
  }
1236
1236
 
1237
1237
  const generation = ++recordingGeneration;
1238
- transcriptionOperationId = nextSpeechOperationId('transcription');
1238
+ const operationId = nextSpeechOperationId('transcription');
1239
+ transcriptionOperationId = operationId;
1239
1240
  recordingRequested = true;
1240
1241
  localStatus = {
1241
1242
  message: 'Recording. Release to transcribe.',
1242
- tone: ''
1243
+ tone: '',
1244
+ owner: 'recording',
1245
+ generation
1243
1246
  };
1244
1247
  renderControls();
1245
1248
  renderStatus();
@@ -1248,6 +1251,9 @@
1248
1251
  try {
1249
1252
  stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1250
1253
  } catch (error) {
1254
+ if (generation !== recordingGeneration || destroyed) {
1255
+ return false;
1256
+ }
1251
1257
  recordingRequested = false;
1252
1258
  resetRecordPress();
1253
1259
  if (
@@ -1268,7 +1274,7 @@
1268
1274
  {
1269
1275
  bubbles: true,
1270
1276
  composed: true,
1271
- operationId: transcriptionOperationId,
1277
+ operationId,
1272
1278
  publicDetail: {
1273
1279
  ...publicSpeechErrorFields(
1274
1280
  error,
@@ -1278,7 +1284,9 @@
1278
1284
  }
1279
1285
  }
1280
1286
  );
1281
- transcriptionOperationId = null;
1287
+ if (transcriptionOperationId === operationId) {
1288
+ transcriptionOperationId = null;
1289
+ }
1282
1290
  } else {
1283
1291
  reportTranscriptionError(error);
1284
1292
  }
@@ -1287,15 +1295,25 @@
1287
1295
  return false;
1288
1296
  }
1289
1297
 
1298
+ const currentGeneration = generation === recordingGeneration;
1290
1299
  if (
1291
1300
  !recordingRequested
1292
- || generation !== recordingGeneration
1301
+ || !currentGeneration
1293
1302
  || sttRole.state !== 'ready'
1294
1303
  || sttRole.busy
1295
1304
  || destroyed
1296
1305
  ) {
1297
1306
  stopMediaStream(stream);
1298
- resetRecordPress();
1307
+ if (currentGeneration) {
1308
+ recordingRequested = false;
1309
+ resetRecordPress();
1310
+ clearRecordingStatus(generation);
1311
+ if (transcriptionOperationId === operationId) {
1312
+ transcriptionOperationId = null;
1313
+ }
1314
+ renderControls();
1315
+ renderStatus();
1316
+ }
1299
1317
  return false;
1300
1318
  }
1301
1319
 
@@ -1311,6 +1329,7 @@
1311
1329
  const processor = audioContext.createScriptProcessor(4096, 1, 1);
1312
1330
  session = {
1313
1331
  generation,
1332
+ operationId,
1314
1333
  recorder,
1315
1334
  stream,
1316
1335
  audioContext,
@@ -1349,7 +1368,6 @@
1349
1368
  recorder.start();
1350
1369
  return true;
1351
1370
  } catch (error) {
1352
- recordingRequested = false;
1353
1371
  if (session) {
1354
1372
  session.cancelled = true;
1355
1373
  releaseCaptureSession(session);
@@ -1359,8 +1377,11 @@
1359
1377
  } else {
1360
1378
  stopMediaStream(stream);
1361
1379
  }
1362
- resetRecordPress();
1363
- reportTranscriptionError(error);
1380
+ if (generation === recordingGeneration) {
1381
+ recordingRequested = false;
1382
+ resetRecordPress();
1383
+ reportTranscriptionError(error);
1384
+ }
1364
1385
  return false;
1365
1386
  }
1366
1387
  }
@@ -1377,32 +1398,67 @@
1377
1398
  }
1378
1399
  }
1379
1400
 
1401
+ function clearRecordingStatus(generation) {
1402
+ if (
1403
+ localStatus?.owner === 'recording'
1404
+ && localStatus.generation === generation
1405
+ ) {
1406
+ localStatus = null;
1407
+ }
1408
+ }
1409
+
1380
1410
  function stop() {
1411
+ const pendingCaptureStart = recordingRequested && !captureSession;
1412
+ const generation = recordingGeneration;
1413
+ const operationId = transcriptionOperationId;
1381
1414
  recordingRequested = false;
1382
1415
  clearSilenceTimer();
1383
- if (captureSession?.recorder.state !== 'inactive') {
1416
+ if (pendingCaptureStart) {
1417
+ // getUserMedia cannot be cancelled synchronously. Retire this
1418
+ // generation so its eventual settlement cannot touch a retry.
1419
+ recordingGeneration += 1;
1420
+ resetRecordPress();
1421
+ clearRecordingStatus(generation);
1422
+ if (transcriptionOperationId === operationId) {
1423
+ transcriptionOperationId = null;
1424
+ }
1425
+ renderControls();
1426
+ renderStatus();
1427
+ return true;
1428
+ }
1429
+ if (captureSession && captureSession.recorder.state !== 'inactive') {
1384
1430
  captureSession.recorder.stop();
1385
1431
  }
1386
1432
  return true;
1387
1433
  }
1388
1434
 
1389
1435
  async function finalizeCapture(session) {
1436
+ const currentGeneration = session.generation === recordingGeneration;
1437
+ const currentOperation = transcriptionOperationId === session.operationId;
1390
1438
  releaseCaptureSession(session);
1391
1439
  if (captureSession === session) {
1392
1440
  captureSession = null;
1393
1441
  }
1394
- recordingRequested = false;
1395
- resetRecordPress();
1442
+ if (currentGeneration) {
1443
+ recordingRequested = false;
1444
+ resetRecordPress();
1445
+ }
1396
1446
 
1397
1447
  if (
1398
1448
  session.cancelled
1399
- || session.generation !== recordingGeneration
1449
+ || !currentGeneration
1400
1450
  || sttRole.state !== 'ready'
1401
1451
  || sttRole.busy
1402
1452
  || destroyed
1403
1453
  ) {
1404
- renderControls();
1405
- renderStatus();
1454
+ if (currentGeneration) {
1455
+ clearRecordingStatus(session.generation);
1456
+ if (currentOperation) {
1457
+ transcriptionOperationId = null;
1458
+ }
1459
+ renderControls();
1460
+ renderStatus();
1461
+ }
1406
1462
  return false;
1407
1463
  }
1408
1464
 
@@ -1415,12 +1471,13 @@
1415
1471
  'audio.webm',
1416
1472
  { type: 'audio/webm' }
1417
1473
  );
1418
- return transcribeAudio(audioFile);
1474
+ return transcribeAudio(audioFile, session.operationId);
1419
1475
  }
1420
1476
 
1421
- async function transcribeAudio(audioFile) {
1477
+ async function transcribeAudio(audioFile, operationId = null) {
1422
1478
  const generation = ++transcriptionGeneration;
1423
- transcriptionOperationId = transcriptionOperationId
1479
+ transcriptionOperationId = operationId
1480
+ || transcriptionOperationId
1424
1481
  || nextSpeechOperationId('transcription');
1425
1482
  const controller = new AbortController();
1426
1483
  transcriptionAbortController?.abort();
@@ -14,7 +14,10 @@
14
14
  "required": ["name", "version"],
15
15
  "properties": {
16
16
  "name": {"const": "arcane-os"},
17
- "version": {"const": "0.3.2"}
17
+ "version": {
18
+ "type": "string",
19
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$"
20
+ }
18
21
  }
19
22
  },
20
23
  "runtime": {
package/src/cli/main.mjs CHANGED
@@ -47,6 +47,7 @@ const REPORTABLE_COMMANDS=new Set([
47
47
  'native-doctor','native-prepare','new','package','repo','run','targets','test',
48
48
  'update-check','upgrade','verify','verify-bundle','version'
49
49
  ]);
50
+ const MAX_NODE_TIMER_DELAY_MS=2_147_483_647;
50
51
 
51
52
  export const HELP_TEXT=`Arcane OS application SDK ${SDK_VERSION}
52
53
 
@@ -76,7 +77,7 @@ Usage:
76
77
  ${CLI_NAME} mail key status <profile>
77
78
  ${CLI_NAME} mail key delete <profile>
78
79
  ${CLI_NAME} mail send --profile <profile> --from <address> --report-key <id> --report-stdin [--request-timeout <ms>]
79
- ${CLI_NAME} mail serve --profile <profile> --from <address> --app <id> --origin <origin> --allow-to <addresses> [--app-key-stdin] [--host 127.0.0.1] [--port 8025] [--request-timeout <ms>]
80
+ ${CLI_NAME} mail serve --profile <profile> --from <address> --app <id> --origin <origin> [--allow-to <addresses>] [--app-key-stdin] [--host 127.0.0.1] [--port 8025] [--request-timeout <ms>]
80
81
 
81
82
  Development:
82
83
  --sdk-runtime-source <sdk-root> Dev-only live SDK checkout; omitted preserves the workspace runtime mode.
@@ -174,16 +175,17 @@ function readPort(value,defaultValue){
174
175
  return port;
175
176
  }
176
177
 
177
- function readRequestTimeout(value,defaultValue){
178
- if(value===undefined){
179
- return defaultValue;
180
- }
178
+ function readRequestTimeout(value){
179
+ if(value===undefined)return undefined;
181
180
  if(!/^\d+$/u.test(value)){
182
181
  usage(`Invalid request timeout: ${value}.`);
183
182
  }
184
183
  const timeout=Number(value);
185
- if(!Number.isSafeInteger(timeout)||timeout<1_000||timeout>600_000){
186
- usage('Mail request timeout must be an integer between 1000 and 600000 milliseconds.');
184
+ if(!Number.isSafeInteger(timeout)||timeout<1||timeout>MAX_NODE_TIMER_DELAY_MS){
185
+ usage(
186
+ `Mail request timeout must be an integer from 1 through ${MAX_NODE_TIMER_DELAY_MS} `
187
+ +'milliseconds, the Node timer range.'
188
+ );
187
189
  }
188
190
  return timeout;
189
191
  }
@@ -649,8 +651,7 @@ function operationOptions(command,parsed,cwd){
649
651
  profile:values.profile,
650
652
  from:values.from,
651
653
  app:values.app,
652
- origin:values.origin,
653
- 'allow-to':values['allow-to'],
654
+ origin:values.origin
654
655
  })){
655
656
  if(!value)usage(`mail serve requires --${name} <value>.`);
656
657
  }
@@ -664,7 +665,7 @@ function operationOptions(command,parsed,cwd){
664
665
  appKeyStdin:flags.has('app-key-stdin'),
665
666
  host:values.host??'127.0.0.1',
666
667
  port:readPort(values.port,8025),
667
- requestTimeout:readRequestTimeout(values['request-timeout'],30_000),
668
+ requestTimeout:readRequestTimeout(values['request-timeout']),
668
669
  };
669
670
  }
670
671
  if(area==='send'){
@@ -693,7 +694,7 @@ function operationOptions(command,parsed,cwd){
693
694
  from:values.from,
694
695
  reportKey:values['report-key'],
695
696
  reportStdin:true,
696
- requestTimeout:readRequestTimeout(values['request-timeout'],30_000),
697
+ requestTimeout:readRequestTimeout(values['request-timeout']),
697
698
  };
698
699
  }
699
700
  usage('mail requires key set|status|delete <profile>, send, or serve.');
@@ -31,6 +31,11 @@ const SDK_RUNTIME_SOURCE_PROTOCOL='arcane-sdk-runtime-source/1';
31
31
  const SDK_RUNTIME_SOURCE_ARCANE_ROOTS=new Set([
32
32
  'components','css','entities','img','modules','security'
33
33
  ]);
34
+ const SDK_INSTALLED_ARCANE_ROOTS=new Set([
35
+ ...SDK_RUNTIME_SOURCE_ARCANE_ROOTS,
36
+ 'dependencies',
37
+ 'sdk'
38
+ ]);
34
39
  const SDK_RUNTIME_SOURCE_PRIVATE_SEGMENTS=new Set([
35
40
  'node_modules','.git','.hg','.svn','cvs'
36
41
  ]);
@@ -126,7 +131,7 @@ async function canonicalRealDirectory(requested,label){
126
131
  return canonical;
127
132
  }
128
133
 
129
- function sdkRuntimeSourcePathAllowed(relative,{arcaneRoot=false}={}){
134
+ function sdkRuntimeSourcePathAllowed(relative,{arcaneRoots=null}={}){
130
135
  if(relative.length===0)return false;
131
136
  const normalized=relative.map(function normalizeRuntimeSourceSegment(segment){
132
137
  return segment.normalize('NFC').toLowerCase();
@@ -135,11 +140,15 @@ function sdkRuntimeSourcePathAllowed(relative,{arcaneRoot=false}={}){
135
140
  return segment.startsWith('.')||SDK_RUNTIME_SOURCE_PRIVATE_SEGMENTS.has(segment)
136
141
  ||SDK_RUNTIME_SOURCE_PRIVATE_MANIFESTS.has(segment);
137
142
  }))return false;
138
- return !arcaneRoot||SDK_RUNTIME_SOURCE_ARCANE_ROOTS.has(normalized[0]);
143
+ return arcaneRoots===null||arcaneRoots.has(normalized[0]);
139
144
  }
140
145
 
141
146
  function sdkArcaneSourcePathAllowed(relative){
142
- return sdkRuntimeSourcePathAllowed(relative,{arcaneRoot:true});
147
+ return sdkRuntimeSourcePathAllowed(relative,{arcaneRoots:SDK_RUNTIME_SOURCE_ARCANE_ROOTS});
148
+ }
149
+
150
+ function sdkInstalledArcanePathAllowed(relative){
151
+ return sdkRuntimeSourcePathAllowed(relative,{arcaneRoots:SDK_INSTALLED_ARCANE_ROOTS});
143
152
  }
144
153
 
145
154
  function sdkDependencySourcePathAllowed(relative){
@@ -418,7 +427,7 @@ async function sourceRoutes(workspaceRoot,appId,{
418
427
  {
419
428
  prefix:['arcane'],
420
429
  root:runtimeRoot,
421
- allow:sdkArcaneSourcePathAllowed
430
+ allow:sdkInstalledArcanePathAllowed
422
431
  }
423
432
  ]
424
433
  };
@@ -1,7 +1,8 @@
1
- import {readFile,realpath,stat} from 'node:fs/promises';
1
+ import {lstat,readFile,realpath,stat,writeFile} from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {materializeWorkspaceRuntimeContent} from './workspace-runtime.mjs';
4
4
  import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
5
+ import {createWorkspaceLockDocument} from './templates/workspace-template.mjs';
5
6
  import {
6
7
  resolveInstalledSdkInstallation,
7
8
  resolveSdkPackageDeclaration
@@ -55,6 +56,18 @@ async function installedSdkAuthority(canonicalRoot,{sdkPackageSource}={}){
55
56
  return {declaration,installation};
56
57
  }
57
58
 
59
+ async function writeWorkspaceLock(lockPath,lock){
60
+ try{
61
+ const info=await lstat(lockPath);
62
+ if(info.isSymbolicLink()||!info.isFile()){
63
+ fail(`Workspace SDK lock must be a real file: ${lockPath}.`);
64
+ }
65
+ }catch(error){
66
+ if(error?.code!=='ENOENT')throw error;
67
+ }
68
+ await writeFile(lockPath,`${JSON.stringify(lock,null,2)}\n`,'utf8');
69
+ }
70
+
58
71
  export async function materializeInstalledSdkRuntime({
59
72
  workspaceRoot,
60
73
  sdkPackageSource,
@@ -79,12 +92,21 @@ export async function materializeInstalledSdkRuntime({
79
92
  signal,
80
93
  onEvent
81
94
  });
95
+ const lockPath=path.join(canonicalRoot,'arcane.lock.json');
96
+ const lock=createWorkspaceLockDocument({
97
+ dependencyName:authority.installation.dependencyName,
98
+ packageName:authority.installation.packageName,
99
+ packageVersion:authority.installation.packageVersion,
100
+ packageSource:authority.installation.packageSource
101
+ });
102
+ await writeWorkspaceLock(lockPath,lock);
82
103
  return {
83
104
  schemaVersion:1,
84
105
  kind:'arcane-installed-sdk-runtime-materialization',
85
106
  status:'materialized',
86
107
  installation:authority.installation,
87
- workspaceRuntime
108
+ workspaceRuntime,
109
+ workspaceLock:{path:lockPath,document:lock}
88
110
  };
89
111
  });
90
112
  }
@@ -19,6 +19,7 @@ const PREFLIGHT_HEADERS=new Set([
19
19
  ]);
20
20
  const PERMANENT_RATE_CODES=new Set(['daily_quota_exceeded','monthly_quota_exceeded']);
21
21
  const RETRYABLE_PROVIDER_STATUSES=new Set([408,425,429,500,502,503,504]);
22
+ const MAX_NODE_TIMER_DELAY_MS=2_147_483_647;
22
23
 
23
24
  class MailGatewayFault extends Error {
24
25
  constructor(code,{details=null,retryable=false,retryAfterMs=0,statusCode=400,uncertain=false}={}){
@@ -61,9 +62,15 @@ function positiveInteger(value,fallback,{label,allowZero=false}={}){
61
62
  return resolved;
62
63
  }
63
64
 
64
- function optionalPositiveInteger(value,label){
65
+ function optionalTimerDelay(value,label){
65
66
  if(value===undefined||value===null) return null;
66
- return positiveInteger(value,null,{label});
67
+ if(!Number.isSafeInteger(value)||value<1||value>MAX_NODE_TIMER_DELAY_MS){
68
+ throw configurationError(
69
+ `${label} must be an integer from 1 through ${MAX_NODE_TIMER_DELAY_MS} `
70
+ +'milliseconds, the Node timer range.'
71
+ );
72
+ }
73
+ return value;
67
74
  }
68
75
 
69
76
  function normalizeRetryAfter(value){
@@ -248,7 +255,7 @@ function normalizeConfiguration(options={}){
248
255
  apiKey:validateApiKey(options.apiKey),
249
256
  appKeyDigest:callerAuthentication.appKeyDigest,
250
257
  appId:validateAppId(options.appId),
251
- bodyTimeoutMs:optionalPositiveInteger(options.bodyTimeoutMs,'bodyTimeoutMs'),
258
+ bodyTimeoutMs:optionalTimerDelay(options.bodyTimeoutMs,'bodyTimeoutMs'),
252
259
  errorRecipients,
253
260
  fetchImpl,
254
261
  from:validateFrom(options.from),
@@ -256,7 +263,7 @@ function normalizeConfiguration(options={}){
256
263
  callerAuthentication:callerAuthentication.callerAuthentication,
257
264
  onEvent:options.onEvent,
258
265
  port:portNumber(options.port,8025),
259
- providerTimeoutMs:optionalPositiveInteger(options.providerTimeoutMs,'providerTimeoutMs'),
266
+ providerTimeoutMs:optionalTimerDelay(options.providerTimeoutMs,'providerTimeoutMs'),
260
267
  requestIdFactory:options.requestIdFactory??randomUUID,
261
268
  retryableDelayMs:positiveInteger(
262
269
  options.retryableDelayMs,
@@ -968,7 +975,7 @@ function normalizeDirectSendOptions(options){
968
975
  errorRecipients:[],
969
976
  fetchImpl,
970
977
  from:validateFrom(options.from),
971
- providerTimeoutMs:optionalPositiveInteger(options.providerTimeoutMs,'providerTimeoutMs'),
978
+ providerTimeoutMs:optionalTimerDelay(options.providerTimeoutMs,'providerTimeoutMs'),
972
979
  requestIdFactory:options.requestIdFactory??randomUUID,
973
980
  retryableDelayMs:positiveInteger(
974
981
  options.retryableDelayMs,
package/src/mail.mjs CHANGED
@@ -40,13 +40,16 @@ function dependency(options,name,fallback){
40
40
  }
41
41
 
42
42
  function recipientList(value,label){
43
+ if(value===undefined||value===null||value===''||(Array.isArray(value)&&value.length===0)){
44
+ return [];
45
+ }
43
46
  const entries=Array.isArray(value)
44
47
  ? value
45
48
  : typeof value==='string'
46
49
  ? value.split(',')
47
50
  : null;
48
- if(!entries||entries.length===0||entries.length>50){
49
- usage(`${label} must contain one to 50 comma-separated email addresses.`);
51
+ if(!entries||entries.length===0){
52
+ usage(`${label} must contain at least one email address when supplied.`);
50
53
  }
51
54
  const result=entries.map(function normalizeMailRecipient(entry){
52
55
  if(typeof entry!=='string'||!entry.trim()){
@@ -111,21 +114,24 @@ async function deleteCredential(options){
111
114
  return remove(credentialOptions(options));
112
115
  }
113
116
 
114
- function safeSendFailure(result){
115
- return {
116
- provider:'resend',
117
- status:result.status,
118
- classification:result.classification,
119
- requestId:result.requestId,
120
- providerStatus:result.providerStatus,
121
- recipientCount:result.recipientCount,
122
- retryable:result.retryable===true,
123
- uncertain:result.uncertain===true,
124
- ...(typeof result.code==='string'?{code:result.code}:{}),
125
- ...(Number.isSafeInteger(result.retryAfterMs)&&result.retryAfterMs>0
126
- ?{retryAfterMs:result.retryAfterMs}
127
- :{})
128
- };
117
+ function withoutMailCredentials(value,seen=new WeakMap()){
118
+ if(!value||typeof value!=='object')return value;
119
+ if(seen.has(value))return seen.get(value);
120
+ const copy=Array.isArray(value)?[]:{};
121
+ seen.set(value,copy);
122
+ for(const [key,entry] of Object.entries(value)){
123
+ if(key==='apiKey'||key==='appKey')continue;
124
+ copy[key]=withoutMailCredentials(entry,seen);
125
+ }
126
+ return copy;
127
+ }
128
+
129
+ function completeSendFailure(result){
130
+ if(!result||typeof result!=='object'||Array.isArray(result)){
131
+ return {provider:'resend',result:withoutMailCredentials(result)};
132
+ }
133
+ // Complete provider detail remains visible; only credential fields are omitted.
134
+ return withoutMailCredentials(result);
129
135
  }
130
136
 
131
137
  async function sendMail(options){
@@ -164,9 +170,9 @@ async function sendMail(options){
164
170
  signal:options.signal
165
171
  });
166
172
  if(result?.classification==='accepted'&&result.status==='accepted'){
167
- return result;
173
+ return withoutMailCredentials(result);
168
174
  }
169
- const details=safeSendFailure(result||{});
175
+ const details=completeSendFailure(result);
170
176
  throw new ArcaneError(
171
177
  ERROR_CODES.operationFailed,
172
178
  `Resend did not authoritatively accept the mail request (${details.classification||'unknown'}).`,
@@ -499,16 +499,23 @@ async function collectPackageRecords(context,{signal}={}){
499
499
  return records;
500
500
  }
501
501
 
502
- async function browserDocuments(records){
502
+ async function browserDocuments(records,entry){
503
+ let entryDocument=null;
503
504
  const documents=[];
504
505
  for(const record of records){
505
- if(path.posix.extname(record.destination).toLocaleLowerCase('en-US')!=='.html')continue;
506
+ const extension=path.posix.extname(record.destination).toLocaleLowerCase('en-US');
507
+ if(extension!=='.html'&&extension!=='.htm')continue;
506
508
  const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{
507
509
  documentPath:record.destination
508
510
  });
509
- documents.push({path:record.destination,...copyJson(inspected)});
511
+ const document={path:record.destination,...copyJson(inspected)};
512
+ if(record.destination===entry){
513
+ entryDocument=document;
514
+ }else if(inspected.bases.length>0){
515
+ documents.push(document);
516
+ }
510
517
  }
511
- return documents;
518
+ return entryDocument===null?documents:[entryDocument,...documents];
512
519
  }
513
520
 
514
521
  async function optionalDescriptor(context){
@@ -540,7 +547,7 @@ async function inspectContext(context,{signal}={}){
540
547
  }),
541
548
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
542
549
  descriptor:await optionalDescriptor(context),
543
- browserDocuments:await browserDocuments(records),
550
+ browserDocuments:await browserDocuments(records,context.config.entry),
544
551
  files:records.map(record=>record.destination),
545
552
  output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
546
553
  };
package/src/scaffold.mjs CHANGED
@@ -430,6 +430,25 @@ export async function initWorkspace({
430
430
  sdkDeclaration
431
431
  );
432
432
  const writtenFiles=await writeMissingFiles(resolvedRoot,template.files,{signal,onEvent});
433
+ if(workspaceMode==='external'&&writtenFiles.skippedFiles.includes('arcane.lock.json')){
434
+ throwIfAborted(signal);
435
+ await assertNoLinkedAncestors(resolvedRoot,'arcane.lock.json');
436
+ const lockPath=path.join(resolvedRoot,'arcane.lock.json');
437
+ const lockInfo=await lstat(lockPath);
438
+ if(lockInfo.isSymbolicLink()||!lockInfo.isFile()){
439
+ fail('Existing arcane.lock.json must be a real file.');
440
+ }
441
+ await writeFile(
442
+ lockPath,
443
+ template.files.get('arcane.lock.json'),
444
+ 'utf8'
445
+ );
446
+ writtenFiles.skippedFiles=writtenFiles.skippedFiles.filter(
447
+ relative=>relative!=='arcane.lock.json'
448
+ );
449
+ writtenFiles.replacedFiles=['arcane.lock.json'];
450
+ await emit(onEvent,{type:'scaffold.file.replaced',path:'arcane.lock.json'});
451
+ }
433
452
  const packageUpdated=await applyPackageMerge(resolvedRoot,packagePlan,{signal,onEvent});
434
453
  const workspaceRuntime=workspaceMode==='external'
435
454
  ?await materializeWorkspaceRuntimeContent({
@@ -1,4 +1,10 @@
1
- import {SDK_NAME,SDK_VERSION} from '../constants.mjs';
1
+ import {
2
+ ARCANE_PROTOCOL,
3
+ CLI_EVENT_PROTOCOL,
4
+ SDK_NAME,
5
+ SDK_VERSION,
6
+ TARGET_ADAPTER_PROTOCOL
7
+ } from '../constants.mjs';
2
8
 
3
9
  const NPM_PACKAGE_NAME_PATTERN=/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
4
10
  const LOCAL_TARBALL_PATTERN=/^file:.+\.tgz$/iu;
@@ -22,6 +28,32 @@ function titleCase(appId){
22
28
  .join(' ');
23
29
  }
24
30
 
31
+ export function createWorkspaceLockDocument({
32
+ dependencyName=SDK_NAME,
33
+ packageName=SDK_NAME,
34
+ packageVersion=SDK_VERSION,
35
+ packageSource=`node_modules/${dependencyName}`
36
+ }={}){
37
+ if(packageName!==SDK_NAME||typeof packageVersion!=='string'
38
+ ||!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(packageVersion)
39
+ ||typeof dependencyName!=='string'||dependencyName.length>214
40
+ ||!NPM_PACKAGE_NAME_PATTERN.test(dependencyName)
41
+ ||packageSource!==`node_modules/${dependencyName}`){
42
+ throw new Error('Invalid workspace SDK lock authority.');
43
+ }
44
+ return {
45
+ schemaVersion:1,
46
+ sdk:{name:packageName,version:packageVersion},
47
+ runtime:{root:`${packageSource}/runtime`},
48
+ sdkBrowserRuntime:{root:`${packageSource}/browser-runtime`},
49
+ protocols:{
50
+ arcane:ARCANE_PROTOCOL,
51
+ cliEvents:CLI_EVENT_PROTOCOL,
52
+ targetAdapter:TARGET_ADAPTER_PROTOCOL
53
+ }
54
+ };
55
+ }
56
+
25
57
  export function workspaceTemplate({
26
58
  appId,
27
59
  displayName,
@@ -212,6 +244,10 @@ Every browser release also carries Arcane OS licensing material under \`licenses
212
244
  ]
213
245
  }
214
246
  }));
247
+ files.set('arcane.lock.json',json(createWorkspaceLockDocument({
248
+ dependencyName:sdkDependencyName,
249
+ packageSource:sdkPackageSource
250
+ })));
215
251
  files.set(`apps/${appId}/arcane-app.json`,json({
216
252
  schemaVersion:2,
217
253
  id:appId,
package/src/toolchain.mjs CHANGED
@@ -314,7 +314,9 @@ async function refreshPreparedImportMap(prepared,{signal,onEvent,workspaceOperat
314
314
  appId:prepared.appId,
315
315
  appRoot:prepared.appRoot,
316
316
  entry:manifest.entry,
317
- documents:inspected.browserDocuments,
317
+ documents:inspected.browserDocuments.map(function selectBrowserDocumentPath(document){
318
+ return document.path;
319
+ }),
318
320
  workspaceOperationLease,
319
321
  signal,
320
322
  onEvent
package/src/workspace.mjs CHANGED
@@ -94,6 +94,18 @@ function dependencyNameForSdkPackageSource(source){
94
94
  }
95
95
  }
96
96
 
97
+ function routeIncludeMatches(actual,wanted,optionalSecurity){
98
+ if(!Array.isArray(actual)||!Array.isArray(wanted))return false;
99
+ if(actual.length===wanted.length
100
+ &&actual.every(function sameIncludedPath(value,index){return value===wanted[index];})){
101
+ return true;
102
+ }
103
+ return optionalSecurity
104
+ &&wanted.at(-1)==='security'
105
+ &&actual.length===wanted.length-1
106
+ &&actual.every(function sameFunctionalPath(value,index){return value===wanted[index];});
107
+ }
108
+
97
109
  export function resolveSdkPackageDeclaration(rootPackage,{
98
110
  allowMissing=false,
99
111
  packageSource
@@ -179,19 +191,30 @@ function classifyRootConfig(config){
179
191
  include:['index.js','licence','package.json']
180
192
  }
181
193
  ];
182
- const matches=expected=>routes.length===expected.length&&routes.every((route,index)=>{
183
- const wanted=expected[index];
184
- return isObject(route)&&Object.keys(route).every(key=>['source','destination','include','exclude'].includes(key))
185
- &&route.source===wanted.source&&route.destination===wanted.destination
186
- &&JSON.stringify(route.include)===JSON.stringify(wanted.include)
187
- &&Array.isArray(route.exclude)&&route.exclude.length===0;
188
- });
194
+ const matches=(expected,{optionalArcaneSecurity=false}={})=>{
195
+ if(routes.length!==expected.length)return false;
196
+ return routes.every(function routeMatches(route,index){
197
+ const wanted=expected[index];
198
+ return isObject(route)
199
+ &&Object.keys(route).every(function knownRouteKey(key){
200
+ return ['source','destination','include','exclude'].includes(key);
201
+ })
202
+ &&route.source===wanted.source&&route.destination===wanted.destination
203
+ &&routeIncludeMatches(
204
+ route.include,
205
+ wanted.include,
206
+ optionalArcaneSecurity&&index===0
207
+ &&wanted.source==='arcane'&&wanted.destination==='arcane'
208
+ )
209
+ &&Array.isArray(route.exclude)&&route.exclude.length===0;
210
+ });
211
+ };
189
212
  let workspaceMode;
190
213
  let browserRuntimeLayout;
191
- if(matches(external)){
214
+ if(matches(external,{optionalArcaneSecurity:true})){
192
215
  workspaceMode='external';
193
216
  browserRuntimeLayout='physical-v1';
194
- }else if(matches(integrated)){
217
+ }else if(matches(integrated,{optionalArcaneSecurity:true})){
195
218
  workspaceMode='integrated';
196
219
  browserRuntimeLayout='physical-v1';
197
220
  }else if(matches(integratedLegacy)){