arcane-os 0.64.0 → 0.65.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/core/client.mjs +62 -5
  4. package/docs/guides/native-whisper-openvino.md +334 -0
  5. package/docs/reference/README.md +4 -0
  6. package/docs/reference/arcane-ollama.md +1 -1
  7. package/docs/reference/codex-app-server.md +269 -0
  8. package/docs/reference/core-client.md +60 -2
  9. package/docs/reference/core-development.md +157 -0
  10. package/docs/reference/core-native-packaging.md +13 -0
  11. package/docs/reference/core-repositories.md +112 -3
  12. package/docs/reference/inventory/package-api.json +143 -3
  13. package/docs/reference/inventory/runtime-modules.json +3 -2
  14. package/docs/reference/local-ai.md +73 -3
  15. package/docs/reference/local-image-generation.md +5 -0
  16. package/docs/reference/local-image-sdxl.md +1 -1
  17. package/docs/reference/mcp-stdio.md +263 -0
  18. package/docs/reference/runtime-components.md +18 -1
  19. package/docs/reference/runtime-modules.md +10 -2
  20. package/docs/reference/sdk-api.md +251 -1
  21. package/package.json +6 -2
  22. package/runtime/arcane/modules/AI.js +156 -13
  23. package/runtime/arcane/modules/Ollama.js +1 -1
  24. package/runtime/arcane/modules/OllamaModelIdentifier.js +13 -0
  25. package/schemas/arcane-app.schema.json +8 -1
  26. package/schemas/arcane-package.schema.json +8 -1
  27. package/src/codex/app-server.mjs +845 -0
  28. package/src/core/development.mjs +457 -0
  29. package/src/core/repositories.mjs +170 -5
  30. package/src/core/services/local-ai.mjs +80 -10
  31. package/src/dev-server.mjs +3 -2
  32. package/src/index.mjs +1 -0
  33. package/src/local-ai/dev.mjs +80 -426
  34. package/src/local-ai/whisper/config.mjs +31 -3
  35. package/src/local-ai/whisper/index.mjs +62 -5
  36. package/src/local-ai/whisper/install.mjs +72 -14
  37. package/src/local-ai/whisper/native/main.cpp +33 -2
  38. package/src/local-ai/whisper/openvino-build.mjs +138 -0
  39. package/src/mcp/stdio-server.mjs +579 -0
  40. package/src/native/portable-layout.mjs +3 -1
  41. package/src/toolchain.mjs +19 -8
@@ -0,0 +1,457 @@
1
+ import path from 'node:path';
2
+ import {pathToFileURL} from 'node:url';
3
+ import Is from 'strong-type';
4
+ import {createCoreRuntime} from './runtime.mjs';
5
+ import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
6
+
7
+ const is = new Is(false);
8
+
9
+ // The managed application import map owns this public module's actual URL.
10
+ const bootstrap = `import {getInstalledCoreClient,installCoreClient} from 'arcane-os/core/client';
11
+ let client=getInstalledCoreClient();
12
+ const installedRuntime=client?.runtime.current();
13
+ if(installedRuntime?.connected===false&&installedRuntime.transport==='standalone'){
14
+ client.close();
15
+ client=null;
16
+ }
17
+ if(!client){
18
+ globalThis.__ARCANE_DEV_HTTP__=true;
19
+ const id=crypto.randomUUID();
20
+ let receive;
21
+ let events;
22
+ let resolveOpen;
23
+ let rejectOpen;
24
+ const pending=new Set();
25
+ const opened=new Promise(function ownConnection(resolve,reject){resolveOpen=resolve;rejectOpen=reject;});
26
+ opened.catch(function observeConnectionFailure(){});
27
+ function connectionFailed(error){
28
+ events.close();
29
+ rejectOpen(error);
30
+ client?.failTransport(error);
31
+ }
32
+ const transport={
33
+ name:'development-http',
34
+ subscribe(listener){
35
+ receive=listener;
36
+ events=new EventSource('/events?client='+encodeURIComponent(id));
37
+ events.onopen=function connectionOpened(){resolveOpen();};
38
+ events.onmessage=function receiveEvent(event){
39
+ try{
40
+ const frame=JSON.parse(event.data);
41
+ if(frame.type==='response')pending.delete(frame.id);
42
+ receive(frame);
43
+ }catch(error){connectionFailed(error);}
44
+ };
45
+ events.onerror=function eventConnectionFailed(event){
46
+ const error=new Error('The Core development connection closed. Reload to reconnect.');
47
+ error.code='CORE_DEV_CONNECTION_CLOSED';
48
+ error.event=event;
49
+ connectionFailed(error);
50
+ };
51
+ return function closeEventConnection(){
52
+ events.close();
53
+ rejectOpen(new Error('The development Core client closed.'));
54
+ };
55
+ },
56
+ async send(frame){
57
+ if(frame.type==='request')pending.add(frame.id);
58
+ if(frame.type==='control'&&frame.control==='requests.cancelAll')frame={...frame,requestIds:[...pending]};
59
+ try{
60
+ await opened;
61
+ const response=await fetch('/rpc?client='+encodeURIComponent(id),{
62
+ method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(frame)
63
+ });
64
+ const result=await response.json();
65
+ if(!response.ok)throw Object.assign(new Error(result.error?.message??'The Core request failed.'),result.error);
66
+ // Responses arrive after their chunks on this same SSE connection.
67
+ }catch(error){
68
+ if(frame.type==='request')pending.delete(frame.id);
69
+ throw error;
70
+ }
71
+ }
72
+ };
73
+ client=installCoreClient(globalThis,{transport,replayRuntimeState:true});
74
+ }
75
+ export {client};
76
+ `;
77
+
78
+ /**
79
+ * Compose one source-development Core without blocking page serving.
80
+ * Application factories retain their options and their native second context.
81
+ */
82
+ export function createDevelopmentCore({
83
+ appRoot = process.cwd(),
84
+ application,
85
+ version,
86
+ services = [],
87
+ serviceModules = [],
88
+ context = {},
89
+ getReplayEvents,
90
+ signal,
91
+ onEvent
92
+ } = {}) {
93
+ const lifetime = new AbortController();
94
+ const operationSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
95
+ const sessions = new Map();
96
+ const requestOwners = new Map();
97
+ const streamOwners = new Map();
98
+ const operations = new Set();
99
+ const incoming = new Set();
100
+ let runtime;
101
+ let stopFrames;
102
+ let preparationError = null;
103
+ let closing = null;
104
+ let closed = false;
105
+
106
+ function diagnostic(error) {
107
+ if (operationSignal.aborted && (error?.name === 'AbortError' || error?.code === 'ARCANE_CANCELLED')) return;
108
+ if (!is.function(onEvent)) {
109
+ console.error('Core development failed.', error);
110
+ return;
111
+ }
112
+ try {
113
+ Promise.resolve(onEvent({type: 'core.development.error', message: error.message, error: serializeCoreError(error)}))
114
+ .catch(function observerFailed(cause) { console.error('Core development observer failed.', cause, error); });
115
+ } catch (cause) { console.error('Core development observer failed.', cause, error); }
116
+ }
117
+
118
+ function sessionFor(id) {
119
+ let session = sessions.get(id);
120
+ if (!session) {
121
+ session = {id, connections: new Set(), requests: new Map(), incoming: new Set(), cancelledRequests: new Set()};
122
+ sessions.set(id, session);
123
+ }
124
+ return session;
125
+ }
126
+
127
+ function writeFrame(connection, frame) {
128
+ const text = `data: ${JSON.stringify(frame)}\n\n`;
129
+ connection.writes = connection.writes.then(function writeNextFrame() {
130
+ if (connection.closed) return;
131
+ return new Promise(function awaitWrite(resolve, reject) {
132
+ function finish(error) {
133
+ connection.response.removeListener('close', disconnected);
134
+ if (error) reject(error);
135
+ else resolve();
136
+ }
137
+ function disconnected() { finish(); }
138
+ connection.response.once('close', disconnected);
139
+ connection.response.write(text, finish);
140
+ });
141
+ }).catch(function streamWriteFailed(error) {
142
+ diagnostic(error);
143
+ connection.response.destroy(error);
144
+ });
145
+ }
146
+
147
+ function sendSession(session, frame) {
148
+ for (const connection of session.connections) writeFrame(connection, frame);
149
+ }
150
+
151
+ function event(event, data) {
152
+ return {protocol: CORE_PROTOCOL, type: 'event', event, data, time: new Date().toISOString()};
153
+ }
154
+
155
+ function routeFrame(frame) {
156
+ if (frame.type === 'response') {
157
+ const owner = requestOwners.get(frame.id);
158
+ if (owner) sendSession(owner, frame);
159
+ return;
160
+ }
161
+ if (frame.type !== 'event') return;
162
+ if (frame.data?.requestId !== undefined) {
163
+ const owner = requestOwners.get(frame.data.requestId);
164
+ if (owner) {
165
+ sendSession(owner, frame);
166
+ return;
167
+ }
168
+ }
169
+ if (frame.data?.streamId !== undefined) {
170
+ const owner = streamOwners.get(frame.data.streamId);
171
+ if (owner) sendSession(owner, frame);
172
+ return;
173
+ }
174
+ for (const session of sessions.values()) sendSession(session, frame);
175
+ }
176
+
177
+ function replay(connection) {
178
+ if (!runtime || connection.closed) return;
179
+ const current = runtime.current();
180
+ if (current.state === 'ready') writeFrame(connection, event('core.ready', {version: current.version, app: current.application}));
181
+ writeFrame(connection, event('core.state', current));
182
+ for (const record of current.services) writeFrame(connection, event('core.service.state', record));
183
+ for (const snapshot of getReplayEvents?.() ?? []) {
184
+ writeFrame(connection, event(snapshot.event, snapshot.data));
185
+ }
186
+ }
187
+
188
+ const ready = Promise.resolve().then(async function prepareApplicationCore() {
189
+ const launchContext = {appRoot, signal: operationSignal, onEvent, ...context};
190
+ runtime = createCoreRuntime({application, version, services});
191
+ stopFrames = runtime.onFrame(routeFrame);
192
+ // Accepted definitions already own resources. Register their cleanup
193
+ // before honoring an abort, even when close precedes this microtask.
194
+ operationSignal.throwIfAborted();
195
+ const composed = await Promise.allSettled(
196
+ serviceModules.map(async function loadApplicationService(selection) {
197
+ const source = pathToFileURL(path.resolve(appRoot, selection.module));
198
+ const imported = await import(source.href);
199
+ operationSignal.throwIfAborted();
200
+ const definition = await imported.default(
201
+ selection.options === undefined ? {} : selection.options,
202
+ launchContext
203
+ );
204
+ runtime.registerService(definition);
205
+ })
206
+ );
207
+ const failures = composed.filter(function rejected(result) {
208
+ return result.status === 'rejected';
209
+ }).map(function cause(result) { return result.reason; });
210
+ // Join every factory before retirement so a slower sibling cannot
211
+ // register a service after close has disposed the prepared runtime.
212
+ if (failures.length === 1) throw failures[0];
213
+ if (failures.length) throw new AggregateError(failures, 'Application Core service composition failed.');
214
+ // Register before starting so each service starts independently and a
215
+ // method waits only for its own service, never every application service.
216
+ operationSignal.throwIfAborted();
217
+ runtime.start();
218
+ return runtime;
219
+ });
220
+ ready.catch(function preparationFailed(error) {
221
+ preparationError = error;
222
+ diagnostic(error);
223
+ });
224
+
225
+ function waitForReady(signal) {
226
+ const waiting = AbortSignal.any([signal, operationSignal]);
227
+ if (waiting.aborted) return Promise.reject(waiting.reason);
228
+ return new Promise(function awaitPreparedCore(resolve, reject) {
229
+ function abort() {
230
+ waiting.removeEventListener('abort', abort);
231
+ reject(waiting.reason);
232
+ }
233
+ waiting.addEventListener('abort', abort, {once: true});
234
+ ready.then(function prepared(value) {
235
+ waiting.removeEventListener('abort', abort);
236
+ if (waiting.aborted) reject(waiting.reason);
237
+ else resolve(value);
238
+ }, function failed(error) {
239
+ waiting.removeEventListener('abort', abort);
240
+ reject(error);
241
+ });
242
+ });
243
+ }
244
+
245
+ function observe(operation) {
246
+ operations.add(operation);
247
+ operation.then(function operationDone() { operations.delete(operation); }, function operationFailed() { operations.delete(operation); });
248
+ return operation;
249
+ }
250
+
251
+ function cancelRequest(id) {
252
+ if (!runtime) return;
253
+ const task = runtime.handle({protocol: CORE_PROTOCOL, type: 'control', control: 'request.cancel', requestId: id});
254
+ observe(task).catch(diagnostic);
255
+ }
256
+
257
+ function disconnect(connection) {
258
+ if (connection.closed) return;
259
+ connection.closed = true;
260
+ const session = connection.session;
261
+ session.connections.delete(connection);
262
+ if (session.connections.size) return;
263
+ for (const request of session.requests.values()) {
264
+ request.controller.abort(new CoreError({code: 'CORE_DEV_DISCONNECTED', message: 'The browser disconnected from Core.'}));
265
+ }
266
+ for (const rpc of session.incoming) {
267
+ rpc.controller.abort(new CoreError({code: 'CORE_DEV_DISCONNECTED', message: 'The browser disconnected from Core.'}));
268
+ if (!rpc.received) rpc.request.destroy(rpc.controller.signal.reason);
269
+ }
270
+ releaseSession(session);
271
+ }
272
+
273
+ function releaseSession(session) {
274
+ if (!session.connections.size && !session.requests.size && !session.incoming.size
275
+ && sessions.get(session.id) === session) sessions.delete(session.id);
276
+ }
277
+
278
+ function json(response, status, value) {
279
+ if (response.destroyed || response.writableEnded) return;
280
+ response.writeHead(status, {'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store'});
281
+ response.end(JSON.stringify(value));
282
+ }
283
+
284
+ async function dispatch(frame, session, controller) {
285
+ if (frame?.protocol !== CORE_PROTOCOL) throw new CoreError({code: 'INVALID_RPC_REQUEST', message: 'Unknown Core protocol.'});
286
+ if (frame.type === 'control' && frame.control === 'requests.cancelAll') {
287
+ for (const id of frame.requestIds ?? []) session.cancelledRequests.add(id);
288
+ for (const request of session.requests.values()) {
289
+ request.controller.abort();
290
+ }
291
+ return;
292
+ }
293
+ if (frame.type === 'control' && frame.control === 'request.cancel') {
294
+ session.cancelledRequests.add(frame.requestId);
295
+ const request = session.requests.get(frame.requestId);
296
+ if (request) {
297
+ request.controller.abort();
298
+ }
299
+ return;
300
+ }
301
+ if (frame.type !== 'request') {
302
+ await waitForReady(controller.signal);
303
+ controller.signal.throwIfAborted();
304
+ operationSignal.throwIfAborted();
305
+ if (frame.type === 'control' && frame.control === 'runtime.replay') {
306
+ for (const connection of session.connections) replay(connection);
307
+ return;
308
+ }
309
+ return runtime.handle(frame);
310
+ }
311
+ if (requestOwners.has(frame.id)) throw new CoreError({code: 'RPC_REQUEST_ID_ACTIVE', message: 'The request id is already active.'});
312
+ const owned = {id: frame.id, controller};
313
+ const streamId = frame.parameters?.streamId;
314
+ session.requests.set(frame.id, owned);
315
+ requestOwners.set(frame.id, session);
316
+ if (session.cancelledRequests.delete(frame.id)) controller.abort();
317
+ if (streamId !== undefined) streamOwners.set(streamId, session);
318
+ function cancelled() { cancelRequest(frame.id); }
319
+ controller.signal.addEventListener('abort', cancelled, {once: true});
320
+ try {
321
+ await waitForReady(controller.signal);
322
+ controller.signal.throwIfAborted();
323
+ operationSignal.throwIfAborted();
324
+ return await runtime.handle(frame);
325
+ } finally {
326
+ controller.signal.removeEventListener('abort', cancelled);
327
+ session.requests.delete(frame.id);
328
+ session.cancelledRequests.delete(frame.id);
329
+ requestOwners.delete(frame.id);
330
+ if (streamId !== undefined) streamOwners.delete(streamId);
331
+ releaseSession(session);
332
+ }
333
+ }
334
+
335
+ async function handler(request, response) {
336
+ const url = new URL(request.url, 'http://arcane.local');
337
+ if (!['/rpc', '/events', '/arcane-core.js', '/arcane-local-ai.js'].includes(url.pathname)) return false;
338
+ if (closed) {
339
+ json(response, 503, {error: {code: 'CORE_DEV_CLOSED', message: 'Core development is closed.'}});
340
+ return true;
341
+ }
342
+ if (url.pathname === '/arcane-core.js' || url.pathname === '/arcane-local-ai.js') {
343
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
344
+ json(response, 405, {error: {message: 'Use GET to load the Core bootstrap.'}});
345
+ return true;
346
+ }
347
+ response.writeHead(200, {'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store'});
348
+ response.end(request.method === 'HEAD' ? undefined : bootstrap);
349
+ return true;
350
+ }
351
+ const clientId = url.searchParams.get('client');
352
+ if (!clientId) {
353
+ json(response, 400, {error: {code: 'CORE_DEV_CLIENT_REQUIRED', message: 'Load the Core development bootstrap before connecting.'}});
354
+ return true;
355
+ }
356
+ const method = url.pathname === '/events' ? 'GET' : 'POST';
357
+ if (request.method !== method) {
358
+ json(response, 405, {error: {message: `Use ${method} for ${url.pathname}.`}});
359
+ return true;
360
+ }
361
+ const session = sessionFor(clientId);
362
+ if (url.pathname === '/events') {
363
+ const connection = {response, session, closed: false, writes: Promise.resolve()};
364
+ session.connections.add(connection);
365
+ response.on('close', function eventConnectionClosed() { disconnect(connection); });
366
+ response.on('error', diagnostic);
367
+ response.writeHead(200, {
368
+ 'Content-Type': 'text/event-stream; charset=utf-8',
369
+ 'Cache-Control': 'no-store',
370
+ ...(request.httpVersionMajor === 2 ? {} : {Connection: 'keep-alive'})
371
+ });
372
+ response.flushHeaders();
373
+ ready.then(function replayReadyRuntime() { replay(connection); }, function reportPreparationFailure(error) {
374
+ if (!connection.closed) writeFrame(connection, event('core.error', serializeCoreError(error)));
375
+ }).catch(diagnostic);
376
+ return true;
377
+ }
378
+ const controller = new AbortController();
379
+ let finishIncoming;
380
+ const finished = new Promise(function awaitIncoming(resolve) { finishIncoming = resolve; });
381
+ const rpc = {request, response, controller, session, finished, received: false};
382
+ incoming.add(rpc);
383
+ session.incoming.add(rpc);
384
+ const abortOnClose = function rpcConnectionClosed() {
385
+ if (!response.writableEnded) controller.abort(new CoreError({code: 'CORE_DEV_DISCONNECTED', message: 'The Core request connection closed.'}));
386
+ };
387
+ response.once('close', abortOnClose);
388
+ let frame;
389
+ try {
390
+ request.setEncoding('utf8');
391
+ let body = '';
392
+ for await (const part of request) body += part;
393
+ frame = JSON.parse(body);
394
+ rpc.received = true;
395
+ const result = await observe(dispatch(frame, session, controller));
396
+ json(response, 200, result ?? {ok: true});
397
+ } catch (error) {
398
+ diagnostic(error);
399
+ if (frame?.type === 'request' && frame.id) {
400
+ const result = {protocol: CORE_PROTOCOL, type: 'response', id: frame.id, ok: false, error: serializeCoreError(error), time: new Date().toISOString()};
401
+ sendSession(session, result);
402
+ json(response, 200, result);
403
+ } else json(response, 400, {error: serializeCoreError(error)});
404
+ } finally {
405
+ incoming.delete(rpc);
406
+ session.incoming.delete(rpc);
407
+ response.removeListener('close', abortOnClose);
408
+ releaseSession(session);
409
+ finishIncoming();
410
+ }
411
+ return true;
412
+ }
413
+
414
+ function current() {
415
+ const core = runtime?.current() ?? null;
416
+ return {state: closed ? 'closed' : preparationError ? 'error' : core?.state === 'ready' ? 'ready' : 'preparing',
417
+ error: preparationError ? serializeCoreError(preparationError) : null,
418
+ core};
419
+ }
420
+
421
+ function close() {
422
+ if (closing) return closing;
423
+ closed = true;
424
+ lifetime.abort();
425
+ for (const rpc of incoming) {
426
+ rpc.controller.abort(lifetime.signal.reason);
427
+ // An incomplete request has no accepted service work to drain.
428
+ if (!rpc.received) rpc.request.destroy();
429
+ }
430
+ closing = (async function closeDevelopmentCore() {
431
+ try { await ready; } catch {}
432
+ try {
433
+ await runtime?.close();
434
+ } finally {
435
+ await Promise.allSettled([...operations]);
436
+ await Promise.all([...incoming].map(function finishRequest(rpc) { return rpc.finished; }));
437
+ const connections = [...sessions.values()].flatMap(function liveConnections(session) {
438
+ return [...session.connections];
439
+ });
440
+ await Promise.all(connections.map(function drainEventWrites(connection) {
441
+ return connection.writes;
442
+ }));
443
+ for (const connection of connections) connection.response.end();
444
+ stopFrames?.();
445
+ sessions.clear();
446
+ signal?.removeEventListener('abort', abortDevelopment);
447
+ }
448
+ return current();
449
+ })();
450
+ return closing;
451
+ }
452
+
453
+ function abortDevelopment() { close().catch(diagnostic); }
454
+ signal?.addEventListener('abort', abortDevelopment, {once: true});
455
+ if (signal?.aborted) abortDevelopment();
456
+ return {ready, handler, current, close};
457
+ }
@@ -1,4 +1,4 @@
1
- import {mkdir, readdir} from 'node:fs/promises';
1
+ import {mkdir, readdir, writeFile} from 'node:fs/promises';
2
2
  import {homedir} from 'node:os';
3
3
  import path from 'node:path';
4
4
  import Is from 'strong-type';
@@ -60,7 +60,7 @@ export function createRepositoryWorkspace(
60
60
  let opened = false;
61
61
  let closing = null;
62
62
 
63
- async function prepare(signal) {
63
+ async function prepare(signal, execute = run) {
64
64
  throwIfAborted(signal);
65
65
  if (opened) return {directory: repositoryDirectory, cloned: false};
66
66
  let entries;
@@ -74,7 +74,7 @@ export function createRepositoryWorkspace(
74
74
  if (entries.length) {
75
75
  // A parent checkout or a bare object cache is not this connection's
76
76
  // working root. Inspection never changes an existing destination.
77
- const result = await run(
77
+ const result = await execute(
78
78
  'git',
79
79
  ['rev-parse', '--is-inside-work-tree', '--show-prefix'],
80
80
  {cwd: repositoryDirectory, signal, onEvent}
@@ -97,7 +97,7 @@ export function createRepositoryWorkspace(
97
97
  const arguments_ = ['clone', '--progress'];
98
98
  if (branch !== undefined) arguments_.push('--branch', branch);
99
99
  arguments_.push('--', remote, repositoryDirectory);
100
- const result = await run(
100
+ const result = await execute(
101
101
  'git',
102
102
  arguments_,
103
103
  {cwd: path.dirname(repositoryDirectory), signal, onEvent}
@@ -168,6 +168,171 @@ export function createRepositoryWorkspace(
168
168
  );
169
169
  }
170
170
 
171
+ function write({files, message, signal} = {}) {
172
+ let selectedFiles;
173
+ try {
174
+ requireString(message, 'message');
175
+ if (!message.isWellFormed()) throw new TypeError('message cannot be represented completely as UTF-8 text.');
176
+ if (!is.array(files) || files.length === 0) {
177
+ throw new TypeError('write requires at least one explicitly selected file.');
178
+ }
179
+ selectedFiles = Array.from(
180
+ files,
181
+ function snapshotFile(file) {
182
+ const {path: filename, content} = file;
183
+ requireString(filename, 'file.path');
184
+ if (!filename.isWellFormed() || filename.includes('\0')) {
185
+ throw new TypeError('file.path cannot be represented completely as a native filename.');
186
+ }
187
+ if (!is.string(content) || !content.isWellFormed()) {
188
+ throw new TypeError('file.content must be complete UTF-8-representable text.');
189
+ }
190
+ const destination = path.resolve(repositoryDirectory, filename);
191
+ const relative = path.relative(repositoryDirectory, destination);
192
+ const first = relative.split(path.sep)[0];
193
+ if (path.isAbsolute(filename) || !relative || relative === '..'
194
+ || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
195
+ || first.toLowerCase() === '.git') {
196
+ throw new TypeError('file.path must name a working file relative to this repository, outside Git metadata.');
197
+ }
198
+ return {path: filename, content, destination};
199
+ }
200
+ );
201
+ } catch (error) {
202
+ return Promise.reject(error);
203
+ }
204
+
205
+ return accept(
206
+ async function writeRepositoryFiles() {
207
+ const outcome = {
208
+ directory: repositoryDirectory,
209
+ state: 'local',
210
+ stage: 'prepare',
211
+ paths: selectedFiles.map(function selectedPath(file) { return file.path; }),
212
+ writtenPaths: [],
213
+ written: false,
214
+ staged: false,
215
+ committed: false,
216
+ pushed: false,
217
+ outputs: []
218
+ };
219
+ let pendingWrite = false;
220
+
221
+ async function executeGit(stage, args, input, options = {}) {
222
+ throwIfAborted(signal);
223
+ outcome.stage = stage;
224
+ const output = {stage, stdout: '', stderr: ''};
225
+ const observed = {stdout: false, stderr: false};
226
+ outcome.outputs.push(output);
227
+ let beforeStartFailed = false;
228
+
229
+ function consumeOutput({stream, chunk}) {
230
+ observed[stream] = true;
231
+ output[stream] += chunk;
232
+ }
233
+
234
+ async function observeEvent(event) {
235
+ try {
236
+ await onEvent?.(event);
237
+ } catch (error) {
238
+ if (event.type === 'process.starting') beforeStartFailed = true;
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ function recordCompletion(result) {
244
+ if (!result || !is.integer(result.code)) return;
245
+ output.code = result.code;
246
+ output.signal = result.signal ?? null;
247
+ for (const stream of ['stdout', 'stderr']) {
248
+ if (!observed[stream] && is.string(result[stream])) output[stream] = result[stream];
249
+ }
250
+ if (result.code !== 0) return;
251
+ if (stage === 'stage') outcome.staged = true;
252
+ if (stage === 'commit') {
253
+ outcome.committed = true;
254
+ outcome.state = 'committed';
255
+ }
256
+ if (stage === 'push') {
257
+ outcome.pushed = true;
258
+ outcome.state = 'pushed';
259
+ }
260
+ }
261
+
262
+ try {
263
+ const result = await run(
264
+ 'git',
265
+ args,
266
+ {cwd: options.cwd ?? repositoryDirectory, signal, onEvent: observeEvent,
267
+ onOutput: consumeOutput, captureOutput: false, input}
268
+ );
269
+ recordCompletion(result);
270
+ if (result.code !== 0) {
271
+ throw new ArcaneError(
272
+ 'ARCANE_OPERATION_FAILED',
273
+ `Git ${stage} exited with code ${String(result.code)}.`,
274
+ {details: result}
275
+ );
276
+ }
277
+ return {...result, stdout: output.stdout, stderr: output.stderr};
278
+ } catch (error) {
279
+ if (!beforeStartFailed) recordCompletion(error?.details);
280
+ const notStarted = beforeStartFailed
281
+ || (error?.code === 'ARCANE_PREREQUISITE_MISSING' && !error?.details)
282
+ || (error?.code === 'ARCANE_CANCELLED' && !error?.details);
283
+ if (!notStarted && output.code !== 0 && (stage === 'commit' || stage === 'push')) {
284
+ outcome.state = 'uncertain';
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ async function* commitMessage() {
291
+ yield message;
292
+ }
293
+
294
+ try {
295
+ await prepare(
296
+ signal,
297
+ function prepareGit(command, args, options) {
298
+ return executeGit('prepare', args, undefined, options);
299
+ }
300
+ );
301
+ outcome.stage = 'write';
302
+ for (const file of selectedFiles) {
303
+ throwIfAborted(signal);
304
+ outcome.path = file.path;
305
+ await mkdir(path.dirname(file.destination), {recursive: true});
306
+ throwIfAborted(signal);
307
+ pendingWrite = true;
308
+ await writeFile(file.destination, file.content, {encoding: 'utf8', signal});
309
+ pendingWrite = false;
310
+ outcome.writtenPaths.push(file.path);
311
+ }
312
+ outcome.written = true;
313
+ delete outcome.path;
314
+ await executeGit('stage', ['--literal-pathspecs', 'add', '--', ...outcome.paths]);
315
+ await executeGit(
316
+ 'commit',
317
+ ['--literal-pathspecs', 'commit', '--only', '--cleanup=verbatim', '--file', '-', '--', ...outcome.paths],
318
+ commitMessage()
319
+ );
320
+ await executeGit('push', ['push']);
321
+ outcome.stage = 'complete';
322
+ return outcome;
323
+ } catch (cause) {
324
+ if (pendingWrite) outcome.state = 'uncertain';
325
+ throw new ArcaneError(
326
+ cause?.code ?? 'ARCANE_REPOSITORY_WRITE_FAILED',
327
+ cause instanceof Error ? cause.message : String(cause),
328
+ {cause, details: outcome, exitCode: cause?.exitCode}
329
+ );
330
+ }
331
+ },
332
+ signal
333
+ );
334
+ }
335
+
171
336
  function close() {
172
337
  if (!closing) {
173
338
  closing = Promise.allSettled([...pending]).then(
@@ -188,7 +353,7 @@ export function createRepositoryWorkspace(
188
353
  return closing;
189
354
  }
190
355
 
191
- return {directory: repositoryDirectory, open, status, pull, push, close, drain: close, dispose: close};
356
+ return {directory: repositoryDirectory, open, status, pull, push, write, close, drain: close, dispose: close};
192
357
  }
193
358
 
194
359
  function requireString(value, name) {