arcane-os 0.91.0 → 0.93.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.
@@ -38,7 +38,7 @@ namespace Arcane.Core.Hosts.Windows
38
38
  {
39
39
  if (appControl == null) return;
40
40
  appControlScript = await browser.AddScriptToExecuteOnDocumentCreatedAsync(
41
- "globalThis[" + AppControlDocumentKey + "] = globalThis.crypto.randomUUID();");
41
+ "globalThis[" + AppControlDocumentKey + "] ??= globalThis.crypto.randomUUID();");
42
42
  if (closing) return;
43
43
  browser.NavigationStarting += AppControlNavigationStarting;
44
44
  browser.NavigationCompleted += AppControlNavigationCompleted;
@@ -140,7 +140,7 @@ namespace Arcane.Core.Hosts.Windows
140
140
  { "url", webView.Source == null ? null : webView.Source.AbsoluteUri },
141
141
  { "documentGeneration", appControlGeneration }, { "ready", AppControlDocumentReady() },
142
142
  { "window", AppControlWindow() },
143
- { "operations", new string[] { "status", "inspect", "capture", "act", "key", "resize" } }
143
+ { "operations", new string[] { "status", "diagnostics", "inspect", "capture", "act", "key", "resize" } }
144
144
  };
145
145
  }
146
146
 
@@ -158,6 +158,7 @@ namespace Arcane.Core.Hosts.Windows
158
158
  {
159
159
  cancellation.ThrowIfCancellationRequested();
160
160
  if (method == "app.control.status") return AppControlStatus();
161
+ if (method == "app.control.diagnostics") return nativeDiagnostics.Snapshot(parameters);
161
162
  if (method != "app.control.inspect" && method != "app.control.capture" && method != "app.control.act"
162
163
  && method != "app.control.key" && method != "app.control.resize")
163
164
  throw new ArgumentException("Unknown app-control method: " + method);
@@ -5,7 +5,7 @@ import Is from 'strong-type';
5
5
  import {ArcaneError, throwIfAborted} from '../errors.mjs';
6
6
  import {runProcess} from '../process.mjs';
7
7
  import {captureRepositoryRemote, captureRepositoryTarget, repositoryConfiguration, pullRepositoryTarget, pushRepositoryTarget,
8
- repositoryPushArguments, repositoryStatus} from '../repository.mjs';
8
+ repositoryHeadArguments, repositoryHeadState, repositoryPushArguments, repositoryStatus} from '../repository.mjs';
9
9
  import {createGitIdentityRunner} from '../git-identity.mjs';
10
10
 
11
11
  export {readGitIdentity} from '../git-identity.mjs';
@@ -45,7 +45,7 @@ export function resolveArcaneDataPaths({dataRoot} = {}) {
45
45
 
46
46
  /** One connected checkout, composed into an application's own Core service. */
47
47
  export function createRepositoryWorkspace(
48
- {name, directory, dataRoot, remote, remoteBase, branch, longPaths, cloneIdentity = {}, gitIdentity, onEvent, run = runProcess} = {}
48
+ {name, directory, dataRoot, remote, remoteBase, branch, initialBranch, longPaths, cloneIdentity = {}, gitIdentity, onEvent, run = runProcess} = {}
49
49
  ) {
50
50
  let selected = directory;
51
51
  if (selected === undefined) {
@@ -59,6 +59,12 @@ export function createRepositoryWorkspace(
59
59
  if (remote !== undefined) requireString(remote, 'remote');
60
60
  const remoteArgument = captureRepositoryRemote(remote, remoteBase);
61
61
  if (branch !== undefined) requireString(branch, 'branch');
62
+ if (initialBranch !== undefined) {
63
+ requireString(initialBranch, 'initialBranch');
64
+ if (!initialBranch.isWellFormed() || initialBranch.includes('\0')) {
65
+ throw new TypeError('initialBranch must be text representable as a native process argument.');
66
+ }
67
+ }
62
68
  if (longPaths !== undefined && !is.boolean(longPaths)) {
63
69
  throw new TypeError('longPaths must be a boolean when supplied.');
64
70
  }
@@ -126,9 +132,60 @@ export function createRepositoryWorkspace(
126
132
  arguments_,
127
133
  {cwd: path.dirname(repositoryDirectory), signal, onEvent}
128
134
  );
135
+ let outputs;
136
+ if (initialBranch !== undefined) {
137
+ const options = {cwd: repositoryDirectory, signal, onEvent};
138
+ outputs = [];
139
+ function completed(operation, observation) {
140
+ outputs.push({operation, stdout: observation.stdout, stderr: observation.stderr,
141
+ code: observation.code, signal: observation.signal ?? null});
142
+ }
143
+ completed('clone', result);
144
+ try {
145
+ throwIfAborted(signal);
146
+ const advertised = await prepareRun('git', ['ls-remote', '--symref', '--', remoteArgument],
147
+ {cwd: path.dirname(repositoryDirectory), signal, onEvent});
148
+ completed('observeRemote', advertised);
149
+ throwIfAborted(signal);
150
+ if (advertised.code !== 0) {
151
+ throw new ArcaneError('ARCANE_OPERATION_FAILED', 'Git remote observation failed.', {details: advertised});
152
+ }
153
+ if (advertised.stdout === '') {
154
+ const state = await prepareRun('git', [...repositoryHeadArguments], options);
155
+ completed('observeHead', state);
156
+ throwIfAborted(signal);
157
+ if (state.code !== 0) {
158
+ throw new ArcaneError('ARCANE_OPERATION_FAILED', 'Git branch state observation failed.', {details: state});
159
+ }
160
+ if (repositoryHeadState(state.stdout, state).unborn) {
161
+ const references = await prepareRun('git', ['for-each-ref', '--format=%(refname)'], options);
162
+ completed('observeRefs', references);
163
+ throwIfAborted(signal);
164
+ if (references.code !== 0) {
165
+ throw new ArcaneError('ARCANE_OPERATION_FAILED', 'Git local ref observation failed.', {details: references});
166
+ }
167
+ // Git templates or hooks can supply local refs even
168
+ // when the remote and this checkout's HEAD are unborn.
169
+ if (references.stdout === '') {
170
+ const selection = await prepareRun('git', ['symbolic-ref', 'HEAD', `refs/heads/${initialBranch}`], options);
171
+ completed('selectInitialBranch', selection);
172
+ throwIfAborted(signal);
173
+ if (selection.code !== 0) {
174
+ throw new ArcaneError('ARCANE_OPERATION_FAILED', 'Git initial branch selection failed.', {details: selection});
175
+ }
176
+ }
177
+ }
178
+ }
179
+ } catch (cause) {
180
+ throw new ArcaneError(cause?.code ?? 'ARCANE_REPOSITORY_PREPARE_FAILED',
181
+ cause instanceof Error ? cause.message : String(cause),
182
+ {cause, details: {directory: repositoryDirectory, cloned: true, outputs}, exitCode: cause?.exitCode});
183
+ }
184
+ }
129
185
  throwIfAborted(signal);
130
186
  opened = true;
131
- return {directory: repositoryDirectory, cloned: true, stdout: result.stdout, stderr: result.stderr};
187
+ return {directory: repositoryDirectory, cloned: true, stdout: result.stdout, stderr: result.stderr,
188
+ ...(outputs === undefined ? {} : {outputs})};
132
189
  }
133
190
 
134
191
  function accept(operation, signal) {
@@ -0,0 +1,13 @@
1
+ import {readFile} from 'node:fs/promises';
2
+
3
+ /** Project the one canonical diagnostic observer into a document-start script. */
4
+ export async function createNativeDiagnosticsSource() {
5
+ const source = await readFile(new URL('../browser-runtime/core/native-diagnostics.mjs', import.meta.url), 'utf8');
6
+ return `(function installArcaneNativeDiagnostics(global){
7
+ 'use strict';
8
+ if(global.top!==undefined&&global.top!==global)return;
9
+ ${source.replace(/^export (?=(?:async )?function )/gmu, '')}
10
+ installNativeDiagnostics(global);
11
+ })(globalThis);
12
+ `;
13
+ }
@@ -15,11 +15,12 @@ const snapshotRef = 'refs/arcane/text-snapshot';
15
15
  * One application service owns each dedicated cache directory. Construction
16
16
  * performs no I/O; refresh coalesces callers and close drains accepted work.
17
17
  */
18
- export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref, selectPath, gitIdentity, onEvent, run = runProcess} = {}) {
18
+ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref, selectPath, allowUnborn = false, gitIdentity, onEvent, run = runProcess} = {}) {
19
19
  for (const [name, value] of Object.entries({cacheDirectory, remote, ref})) {
20
20
  if (!is.string(value) || value === '') throw new TypeError(`${name} must be a nonempty string.`);
21
21
  }
22
22
  if (!is.function(selectPath)) throw new TypeError('selectPath must be a function.');
23
+ if (!is.boolean(allowUnborn)) throw new TypeError('allowUnborn must be a boolean.');
23
24
  const remoteArgument = captureRepositoryRemote(remote, remoteBase);
24
25
  const execute = createGitIdentityRunner(run, gitIdentity);
25
26
  const directory = path.resolve(cacheDirectory);
@@ -70,11 +71,28 @@ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref,
70
71
  try {
71
72
  await events.send({type: 'git.snapshot.refreshing', message: 'Refreshing the selected repository revision.', data: {ref}});
72
73
  await prepare(events);
74
+ if (allowUnborn) {
75
+ // No ref pattern or --exit-code: a successful complete empty
76
+ // advertisement is distinct from a missing selected ref.
77
+ const advertised = await git(['ls-remote', '--symref', '--', remoteArgument], {onEvent: events.send});
78
+ if (advertised.code !== 0) {
79
+ throw new ArcaneError(ERROR_CODES.operationFailed, 'Git remote observation failed.', {details: advertised});
80
+ }
81
+ if (advertised.stdout === '') {
82
+ const result = {revision: null, files: [], unborn: true};
83
+ await events.send({type: 'git.snapshot.completed', message: 'The remote advertises no revision.',
84
+ data: {revision: null, unborn: true, reused: retained?.unborn === true}});
85
+ await events.drain();
86
+ retained = result;
87
+ return result;
88
+ }
89
+ }
73
90
  await git(['fetch', '--no-tags', '--no-write-fetch-head', '--', remoteArgument, `+${ref}:${snapshotRef}`], {onEvent: events.send});
74
91
  const selected = await git(['rev-parse', '--verify', `${snapshotRef}^{commit}`], {onEvent: events.send});
75
92
  const revision = selected.stdout.trim();
76
93
  if (retained?.revision === revision) {
77
- await events.send({type: 'git.snapshot.completed', message: 'The selected repository revision is unchanged.', data: {revision, reused: true}});
94
+ await events.send({type: 'git.snapshot.completed', message: 'The selected repository revision is unchanged.',
95
+ data: {revision, reused: true, ...(allowUnborn ? {unborn: false} : {})}});
78
96
  await events.drain();
79
97
  return retained;
80
98
  }
@@ -125,8 +143,9 @@ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref,
125
143
  }
126
144
  });
127
145
  }
128
- const result = {revision, files: reader.finish()};
129
- await events.send({type: 'git.snapshot.completed', message: 'Repository text snapshot is ready.', data: {revision, reused: false}});
146
+ const result = {revision, files: reader.finish(), ...(allowUnborn ? {unborn: false} : {})};
147
+ await events.send({type: 'git.snapshot.completed', message: 'Repository text snapshot is ready.',
148
+ data: {revision, reused: false, ...(allowUnborn ? {unborn: false} : {})}});
130
149
  await events.drain();
131
150
  retained = result;
132
151
  return result;
@@ -156,7 +175,8 @@ export function createGitTextSnapshot({cacheDirectory, remote, remoteBase, ref,
156
175
  accepted.then(settled, settled);
157
176
  }
158
177
  return waitForSnapshot(active, signal).then(function copyResult(snapshot) {
159
- return {revision: snapshot.revision, files: snapshot.files.map(function copyFile(file) { return {...file}; })};
178
+ return {revision: snapshot.revision, files: snapshot.files.map(function copyFile(file) { return {...file}; }),
179
+ ...(allowUnborn ? {unborn: snapshot.unborn} : {})};
160
180
  });
161
181
  }
162
182
 
@@ -44,6 +44,7 @@ const SDK_BROWSER_SELF_IMPORTS=new Map([
44
44
  ['arcane-os/websocket-client','sdk/websocket-client.mjs'],
45
45
  ['arcane-os/core/client','sdk/core/client.mjs'],
46
46
  ['arcane-os/core/contracts','sdk/core/contracts.mjs'],
47
+ ['arcane-os/core/native-diagnostics','sdk/core/native-diagnostics.mjs'],
47
48
  ['arcane-os/document-acquisition','sdk/document-acquisition.mjs'],
48
49
  ['arcane-os/logging','sdk/logging.mjs'],
49
50
  ['arcane-os/browser-device','sdk/browser-device.mjs'],
package/src/process.mjs CHANGED
@@ -3,6 +3,7 @@ import {spawn} from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  import {ArcaneError,ERROR_CODES,throwIfAborted} from './errors.mjs';
5
5
  import {createEventQueue} from './event-queue.mjs';
6
+ import {windowsGitEnvironment} from './windows-git-executable.mjs';
6
7
 
7
8
  const is = new Is(false);
8
9
 
@@ -195,6 +196,29 @@ export async function runProcess(command,args=[],{
195
196
  });
196
197
  throwIfAborted(signal);
197
198
 
199
+ let childEnvironment=env?{...process.env,...env}:process.env;
200
+ if(process.platform==='win32'&&command==='git'){
201
+ try{
202
+ childEnvironment=await windowsGitEnvironment(childEnvironment,{
203
+ run:runProcess,signal,
204
+ onEvent:function discoveryEvent(event){
205
+ return events.send({
206
+ ...event,type:event.type.replace(/^process\./u,'process.git.discovery.')
207
+ });
208
+ }
209
+ });
210
+ }catch(error){
211
+ throwIfAborted(signal);
212
+ if(events.error)throw events.error;
213
+ await events.send({
214
+ type:'process.git.discovery.unavailable',
215
+ message:'Registered Git discovery was unavailable; using the existing command environment.',
216
+ data:{error}
217
+ });
218
+ }
219
+ throwIfAborted(signal);
220
+ }
221
+
198
222
  return new Promise((resolve,reject)=>{
199
223
  let stdout=outputEncodings.stdout!==null&&outputSelected(captureOutput,'stdout')?'':null;
200
224
  let stderr=outputEncodings.stderr!==null&&outputSelected(captureOutput,'stderr')?'':null;
@@ -369,7 +393,7 @@ export async function runProcess(command,args=[],{
369
393
  try{
370
394
  child=spawn(executable,executableArgs,{
371
395
  cwd,
372
- env:env?{...process.env,...env}:process.env,
396
+ env:childEnvironment,
373
397
  shell:false,
374
398
  detached:process.platform!=='win32',
375
399
  windowsHide:true,
@@ -85,6 +85,25 @@ export function repositoryPushArguments(selection) {
85
85
  : ['push', '--no-follow-tags', '--', selection.remoteArgument, `HEAD:${selection.target.ref}`];
86
86
  }
87
87
 
88
+ // Both configuration and new-clone preparation consume Git's explicit initial
89
+ // state. NUL framing keeps unusual working filenames outside these headers.
90
+ export const repositoryHeadArguments = [
91
+ '--no-optional-locks', 'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=no'
92
+ ];
93
+
94
+ export function repositoryHeadState(output, details) {
95
+ const prefix = '# branch.oid ';
96
+ const record = output.split('\0').find(function branchCommit(value) {
97
+ return value.startsWith(prefix);
98
+ });
99
+ const revision = record?.slice(prefix.length);
100
+ if (!revision) {
101
+ throw new ArcaneError('ARCANE_REPOSITORY_HEAD_UNAVAILABLE',
102
+ 'Git did not report its current commit or initial branch state.', {details});
103
+ }
104
+ return revision === '(initial)' ? {revision: null, unborn: true} : {revision, unborn: false};
105
+ }
106
+
88
107
  /** Observe an existing workspace without initializing or refreshing it. */
89
108
  export async function repositoryConfiguration({workspaceRoot = process.cwd(), signal, onEvent, run = runProcess} = {}) {
90
109
  const repositoryRoot = path.resolve(workspaceRoot);
@@ -147,7 +166,12 @@ export async function repositoryConfiguration({workspaceRoot = process.cwd(), si
147
166
  upstream.urls = remotes.get(`remote.${remoteName}.url`) ?? [];
148
167
  upstream.pushUrls = remotes.get(`remote.${remoteName}.pushurl`) ?? [];
149
168
  }
150
- return {repositoryRoot, headRef, origin, upstream};
169
+ const state = await readGitText([...repositoryHeadArguments], options);
170
+ if (state.result.code !== 0) throw processFailure(state, 'Git branch state observation failed.');
171
+ let headState;
172
+ try { headState = repositoryHeadState(state.output, state.result); }
173
+ catch (error) { error.rawStdout = state.rawStdout; throw error; }
174
+ return {repositoryRoot, headRef, ...headState, origin, upstream};
151
175
  }
152
176
 
153
177
  export async function repositoryStatus({
@@ -0,0 +1,103 @@
1
+ import Is from 'strong-type';
2
+ import {stat} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {throwIfAborted} from './errors.mjs';
5
+
6
+ const is=new Is(false);
7
+ let installedDirectory=null;
8
+
9
+ // Git for Windows records its chosen location for third-party integrations.
10
+ // Read both installation scopes/views in one owned Windows process. No guessed
11
+ // installation paths or changes to the registry/environment are needed.
12
+ const READ_INSTALLATIONS=String.raw`
13
+ $ErrorActionPreference = 'Stop'
14
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
15
+ $installations = [System.Collections.Generic.List[string]]::new()
16
+ $views = @([Microsoft.Win32.RegistryView]::Registry32)
17
+ if ([Environment]::Is64BitOperatingSystem) {
18
+ $views = @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)
19
+ }
20
+ foreach ($hive in @([Microsoft.Win32.RegistryHive]::CurrentUser, [Microsoft.Win32.RegistryHive]::LocalMachine)) {
21
+ foreach ($view in $views) {
22
+ $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view)
23
+ try {
24
+ $key = $base.OpenSubKey('Software\GitForWindows')
25
+ if ($null -ne $key) {
26
+ try {
27
+ $location = $key.GetValue('InstallPath')
28
+ if ($location -is [string] -and $location.Length -gt 0 -and -not $installations.Contains($location)) {
29
+ $installations.Add($location)
30
+ }
31
+ } finally { $key.Dispose() }
32
+ }
33
+ } finally { $base.Dispose() }
34
+ }
35
+ }
36
+ ConvertTo-Json -InputObject @($installations.ToArray()) -Compress
37
+ `;
38
+
39
+ function environmentKey(environment,name){
40
+ return Object.keys(environment).sort().find(function matchingKey(key){
41
+ return key.toLowerCase()===name;
42
+ });
43
+ }
44
+
45
+ export function appendWindowsGitDirectory(environment,directory){
46
+ const keys=Object.keys(environment).filter(function pathKey(key){
47
+ return key.toLowerCase()==='path';
48
+ }).sort();
49
+ const selectedKey=keys[0]??'Path';
50
+ // Node chooses the first case-insensitive key before omitting undefined.
51
+ // libuv uses the parent's PATH if the child has none; null becomes 'null'.
52
+ const selected=environment[selectedKey];
53
+ const original=selected===undefined
54
+ ?process.env[environmentKey(process.env,'path')]??'':selected;
55
+ const result={...environment};
56
+ for(const key of keys)delete result[key];
57
+ const originalPath=`${original}`;
58
+ result[selectedKey]=`${originalPath}${originalPath?';':''}"${directory}"`;
59
+ return result;
60
+ }
61
+
62
+ async function installedGitExists(directory){
63
+ try{
64
+ return (await stat(path.win32.join(directory,'git.exe'))).isFile();
65
+ }catch(error){
66
+ if(error.code==='ENOENT'||error.code==='ENOTDIR')return false;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ export async function windowsGitEnvironment(environment,{run,signal,onEvent}={}){
72
+ throwIfAborted(signal);
73
+ const retained=installedDirectory;
74
+ if(retained){
75
+ const available=await installedGitExists(retained);
76
+ throwIfAborted(signal);
77
+ if(available)return appendWindowsGitDirectory(environment,retained);
78
+ if(installedDirectory===retained)installedDirectory=null;
79
+ }
80
+
81
+ const systemRoot=process.env[environmentKey(process.env,'systemroot')];
82
+ if(!systemRoot)return environment;
83
+ const powershell=path.win32.join(systemRoot,'System32','WindowsPowerShell','v1.0','powershell.exe');
84
+ const result=await run(powershell,['-NoProfile','-NonInteractive','-Command',READ_INSTALLATIONS],{
85
+ signal,onEvent,emitOutputEvents:false
86
+ });
87
+ throwIfAborted(signal);
88
+ const locations=JSON.parse(result.stdout);
89
+ if(!is.array(locations))throw new TypeError('Git installation discovery did not return a location list.');
90
+ for(const location of locations){
91
+ if(!is.string(location)||!path.win32.isAbsolute(location))continue;
92
+ const directory=path.win32.join(location,'cmd');
93
+ const available=await installedGitExists(directory);
94
+ throwIfAborted(signal);
95
+ if(available){
96
+ // Only successful, still-present discovery is retained. Concurrent
97
+ // first callers own separate lookups and cancellation independently.
98
+ installedDirectory=directory;
99
+ return appendWindowsGitDirectory(environment,directory);
100
+ }
101
+ }
102
+ return environment;
103
+ }