@superdoc/sdk 2.19.0-next.9 → 2.19.1-next.1

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.
@@ -3,6 +3,7 @@
3
3
  var node_child_process = require('node:child_process');
4
4
  var transportCommon = require('./transport-common.cjs');
5
5
  var errors = require('./errors.cjs');
6
+ var stateDirectory = require('./state-directory.cjs');
6
7
  var documentEvidence = require('./document-evidence.cjs');
7
8
  var contract = require('../generated/contract.cjs');
8
9
  var documentRpc = require('./document-rpc.cjs');
@@ -84,6 +85,8 @@ function resolveJsWatchdogTimeout(watchdogTimeoutMs, requestTimeoutMs, timeoutMs
84
85
  }
85
86
  /** Transport for the legacy CLI host or the structured document host. */
86
87
  class HostTransport {
88
+ stateDirectory;
89
+ stoppedChildren = new Map();
87
90
  hostBin;
88
91
  processMode;
89
92
  env;
@@ -112,6 +115,7 @@ class HostTransport {
112
115
  documentRpcMutationStates = new Map();
113
116
  indeterminateDiscardCloses = new Set();
114
117
  constructor(options) {
118
+ this.stateDirectory = new stateDirectory.SdkStateDirectory(options.stateDir);
115
119
  this.hostBin = options.hostBin;
116
120
  this.processMode = options.processMode ?? 'cli';
117
121
  this.env = options.env;
@@ -175,7 +179,11 @@ class HostTransport {
175
179
  return this.disposePromise;
176
180
  if (this.terminalDisposalError)
177
181
  throw this.terminalDisposalError;
178
- if (!this.child && !this.connecting && this.activeCalls.size === 0)
182
+ if (!this.child &&
183
+ !this.connecting &&
184
+ this.activeCalls.size === 0 &&
185
+ !this.stateDirectory.ownsRoot &&
186
+ this.stoppedChildren.size === 0)
179
187
  return;
180
188
  this.stopping = true;
181
189
  const disposal = this.disposeAfterActiveCalls();
@@ -193,8 +201,11 @@ class HostTransport {
193
201
  await Promise.allSettled(Array.from(this.activeCalls));
194
202
  if (this.terminalDisposalError)
195
203
  throw this.terminalDisposalError;
196
- if (!this.child)
204
+ if (!this.child) {
205
+ await this.waitForStoppedChildren();
206
+ await this.stateDirectory.dispose();
197
207
  return;
208
+ }
198
209
  try {
199
210
  await this.reconcileIndeterminateDiscardCloses();
200
211
  await this.persistDirtyDocumentRpcSessions();
@@ -212,17 +223,16 @@ class HostTransport {
212
223
  catch {
213
224
  // ignore and force shutdown below
214
225
  }
215
- await new Promise((resolve) => {
216
- const timer = setTimeout(() => {
217
- child.kill('SIGKILL');
218
- resolve();
219
- }, this.shutdownTimeoutMs);
220
- child.once('close', () => {
221
- clearTimeout(timer);
222
- resolve();
226
+ await this.waitForStoppedChildren(true);
227
+ if (child.exitCode !== 0) {
228
+ this.terminalDisposalError = new errors.SuperDocCliError('Host shutdown failed; its state directory has been retained.', {
229
+ code: 'HOST_SHUTDOWN_FAILED',
230
+ details: { path: this.stateDirectory.path, exitCode: child.exitCode, signal: child.signalCode },
223
231
  });
224
- });
232
+ throw this.terminalDisposalError;
233
+ }
225
234
  this.cleanupProcess(null);
235
+ await this.stateDirectory.dispose();
226
236
  }
227
237
  async invoke(operation, params = {}, options = {}) {
228
238
  return this.runWhileActive(() => this.invokeWhileActive(operation, params, options));
@@ -452,14 +462,15 @@ class HostTransport {
452
462
  const args = this.processMode === 'document'
453
463
  ? buildDocumentHostSpawnArgs(prefixArgs)
454
464
  : buildHostSpawnArgs(prefixArgs, { requestTimeoutMs: this.requestTimeoutMs });
465
+ await this.waitForStoppedChildren();
466
+ const env = { ...process.env, ...this.env };
467
+ env.SUPERDOC_CLI_STATE_DIR = await this.stateDirectory.prepare(env);
455
468
  const child = node_child_process.spawn(command, args, {
456
- env: {
457
- ...process.env,
458
- ...this.env,
459
- },
469
+ env,
460
470
  stdio: ['pipe', 'pipe', 'pipe'],
461
471
  });
462
472
  this.child = child;
473
+ this.stoppedChildren.set(child, new Promise((resolve) => child.once('close', () => resolve())));
463
474
  // JSON-RPC is framed by ASCII LF; Node 24 readline also splits on a raw
464
475
  // U+2028 inside otherwise valid JSON string data.
465
476
  child.stdout.setEncoding('utf8');
@@ -473,6 +484,8 @@ class HostTransport {
473
484
  process.stderr.write(`[superdoc-host] ${String(chunk)}`);
474
485
  });
475
486
  child.on('error', (error) => {
487
+ if (this.child !== child)
488
+ return;
476
489
  this.handleDisconnect(new errors.SuperDocCliError('Host process failed.', {
477
490
  code: 'HOST_DISCONNECTED',
478
491
  details: {
@@ -481,6 +494,8 @@ class HostTransport {
481
494
  }));
482
495
  });
483
496
  child.on('close', (code, signal) => {
497
+ if (this.child !== child)
498
+ return;
484
499
  if (this.stopping && this.dirtyDocumentRpcSessions.size === 0) {
485
500
  this.cleanupProcess(null);
486
501
  return;
@@ -738,11 +753,11 @@ class HostTransport {
738
753
  const cliMessage = typeof data?.message === 'string' ? data.message : undefined;
739
754
  const exitCode = typeof data?.exitCode === 'number' ? data.exitCode : undefined;
740
755
  if (cliCode) {
741
- return new errors.SuperDocCliError(cliMessage ?? error.message ?? 'Command failed.', {
756
+ return this.stateDirectory.mapOperationError(new errors.SuperDocCliError(cliMessage ?? error.message ?? 'Command failed.', {
742
757
  code: cliCode,
743
758
  details: data?.details,
744
759
  exitCode,
745
- });
760
+ }));
746
761
  }
747
762
  if (domainCode) {
748
763
  return new errors.SuperDocCliError(error.message, {
@@ -765,6 +780,33 @@ class HostTransport {
765
780
  details: data,
766
781
  });
767
782
  }
783
+ async waitForStoppedChildren(forceAfterTimeout = false) {
784
+ for (const [child, stopped] of this.stoppedChildren) {
785
+ let timer;
786
+ try {
787
+ await new Promise((resolve, reject) => {
788
+ const fail = () => reject(new errors.SuperDocCliError('Host process did not stop; state has been retained.', {
789
+ code: 'HOST_DISCONNECTED',
790
+ details: { pid: child.pid },
791
+ }));
792
+ timer = setTimeout(() => {
793
+ if (!forceAfterTimeout) {
794
+ fail();
795
+ return;
796
+ }
797
+ child.kill('SIGKILL');
798
+ timer = setTimeout(fail, this.shutdownTimeoutMs);
799
+ }, this.shutdownTimeoutMs);
800
+ void stopped.then(resolve, reject);
801
+ });
802
+ this.stoppedChildren.delete(child);
803
+ }
804
+ finally {
805
+ if (timer)
806
+ clearTimeout(timer);
807
+ }
808
+ }
809
+ }
768
810
  handleDisconnect(error) {
769
811
  this.cleanupProcess(error);
770
812
  }
@@ -784,8 +826,8 @@ class HostTransport {
784
826
  if (child) {
785
827
  if (this.stdoutDataListener)
786
828
  child.stdout.off('data', this.stdoutDataListener);
787
- child.removeAllListeners();
788
- child.kill('SIGKILL');
829
+ if (child.exitCode === null && child.signalCode === null)
830
+ child.kill('SIGKILL');
789
831
  }
790
832
  this.child = null;
791
833
  this.stdoutBuffer = '';
@@ -31,6 +31,8 @@ export declare function buildDocumentHostSpawnArgs(prefixArgs: readonly string[]
31
31
  export declare function resolveJsWatchdogTimeout(watchdogTimeoutMs: number, requestTimeoutMs: number | undefined, timeoutMsOverride: number | undefined): number;
32
32
  /** Transport for the legacy CLI host or the structured document host. */
33
33
  export declare class HostTransport {
34
+ private readonly stateDirectory;
35
+ private readonly stoppedChildren;
34
36
  private readonly hostBin;
35
37
  private readonly processMode;
36
38
  private readonly env?;
@@ -87,6 +89,7 @@ export declare class HostTransport {
87
89
  private onStdoutLine;
88
90
  private onStdoutChunk;
89
91
  private mapJsonRpcError;
92
+ private waitForStoppedChildren;
90
93
  private handleDisconnect;
91
94
  private cleanupProcess;
92
95
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { buildOperationArgv, buildStructuredReadInput, normalizeCollaborationAuth, resolveInvocation, } from './transport-common.js';
3
3
  import { SuperDocCliError } from './errors.js';
4
+ import { SdkStateDirectory } from './state-directory.js';
4
5
  import { registerMutationRevision } from './document-evidence.js';
5
6
  import { CONTRACT } from '../generated/contract.js';
6
7
  import { DOCUMENT_RPC_FEATURES, DOCUMENT_RPC_DEFAULT_CHANGE_MODE_FEATURE, DOCUMENT_RPC_REQUEST_TIMEOUT_FEATURE, DOCUMENT_RPC_REPLACE_FILE_FEATURE, DOCUMENT_RPC_SOURCE_SAVE_FEATURE, buildDocumentInvokeParams, buildDocumentOpenParams, canOpenWithDocumentRpc, documentRpcRequestSupportsResponseTimeout, documentRpcEnabled, mapDocumentLifecycleResult, mapDocumentOpenResult, mapDocumentReplaceFileResult, supportsDocumentRpc, } from './document-rpc.js';
@@ -81,6 +82,8 @@ export function resolveJsWatchdogTimeout(watchdogTimeoutMs, requestTimeoutMs, ti
81
82
  }
82
83
  /** Transport for the legacy CLI host or the structured document host. */
83
84
  export class HostTransport {
85
+ stateDirectory;
86
+ stoppedChildren = new Map();
84
87
  hostBin;
85
88
  processMode;
86
89
  env;
@@ -109,6 +112,7 @@ export class HostTransport {
109
112
  documentRpcMutationStates = new Map();
110
113
  indeterminateDiscardCloses = new Set();
111
114
  constructor(options) {
115
+ this.stateDirectory = new SdkStateDirectory(options.stateDir);
112
116
  this.hostBin = options.hostBin;
113
117
  this.processMode = options.processMode ?? 'cli';
114
118
  this.env = options.env;
@@ -172,7 +176,11 @@ export class HostTransport {
172
176
  return this.disposePromise;
173
177
  if (this.terminalDisposalError)
174
178
  throw this.terminalDisposalError;
175
- if (!this.child && !this.connecting && this.activeCalls.size === 0)
179
+ if (!this.child &&
180
+ !this.connecting &&
181
+ this.activeCalls.size === 0 &&
182
+ !this.stateDirectory.ownsRoot &&
183
+ this.stoppedChildren.size === 0)
176
184
  return;
177
185
  this.stopping = true;
178
186
  const disposal = this.disposeAfterActiveCalls();
@@ -190,8 +198,11 @@ export class HostTransport {
190
198
  await Promise.allSettled(Array.from(this.activeCalls));
191
199
  if (this.terminalDisposalError)
192
200
  throw this.terminalDisposalError;
193
- if (!this.child)
201
+ if (!this.child) {
202
+ await this.waitForStoppedChildren();
203
+ await this.stateDirectory.dispose();
194
204
  return;
205
+ }
195
206
  try {
196
207
  await this.reconcileIndeterminateDiscardCloses();
197
208
  await this.persistDirtyDocumentRpcSessions();
@@ -209,17 +220,16 @@ export class HostTransport {
209
220
  catch {
210
221
  // ignore and force shutdown below
211
222
  }
212
- await new Promise((resolve) => {
213
- const timer = setTimeout(() => {
214
- child.kill('SIGKILL');
215
- resolve();
216
- }, this.shutdownTimeoutMs);
217
- child.once('close', () => {
218
- clearTimeout(timer);
219
- resolve();
223
+ await this.waitForStoppedChildren(true);
224
+ if (child.exitCode !== 0) {
225
+ this.terminalDisposalError = new SuperDocCliError('Host shutdown failed; its state directory has been retained.', {
226
+ code: 'HOST_SHUTDOWN_FAILED',
227
+ details: { path: this.stateDirectory.path, exitCode: child.exitCode, signal: child.signalCode },
220
228
  });
221
- });
229
+ throw this.terminalDisposalError;
230
+ }
222
231
  this.cleanupProcess(null);
232
+ await this.stateDirectory.dispose();
223
233
  }
224
234
  async invoke(operation, params = {}, options = {}) {
225
235
  return this.runWhileActive(() => this.invokeWhileActive(operation, params, options));
@@ -449,14 +459,15 @@ export class HostTransport {
449
459
  const args = this.processMode === 'document'
450
460
  ? buildDocumentHostSpawnArgs(prefixArgs)
451
461
  : buildHostSpawnArgs(prefixArgs, { requestTimeoutMs: this.requestTimeoutMs });
462
+ await this.waitForStoppedChildren();
463
+ const env = { ...process.env, ...this.env };
464
+ env.SUPERDOC_CLI_STATE_DIR = await this.stateDirectory.prepare(env);
452
465
  const child = spawn(command, args, {
453
- env: {
454
- ...process.env,
455
- ...this.env,
456
- },
466
+ env,
457
467
  stdio: ['pipe', 'pipe', 'pipe'],
458
468
  });
459
469
  this.child = child;
470
+ this.stoppedChildren.set(child, new Promise((resolve) => child.once('close', () => resolve())));
460
471
  // JSON-RPC is framed by ASCII LF; Node 24 readline also splits on a raw
461
472
  // U+2028 inside otherwise valid JSON string data.
462
473
  child.stdout.setEncoding('utf8');
@@ -470,6 +481,8 @@ export class HostTransport {
470
481
  process.stderr.write(`[superdoc-host] ${String(chunk)}`);
471
482
  });
472
483
  child.on('error', (error) => {
484
+ if (this.child !== child)
485
+ return;
473
486
  this.handleDisconnect(new SuperDocCliError('Host process failed.', {
474
487
  code: 'HOST_DISCONNECTED',
475
488
  details: {
@@ -478,6 +491,8 @@ export class HostTransport {
478
491
  }));
479
492
  });
480
493
  child.on('close', (code, signal) => {
494
+ if (this.child !== child)
495
+ return;
481
496
  if (this.stopping && this.dirtyDocumentRpcSessions.size === 0) {
482
497
  this.cleanupProcess(null);
483
498
  return;
@@ -735,11 +750,11 @@ export class HostTransport {
735
750
  const cliMessage = typeof data?.message === 'string' ? data.message : undefined;
736
751
  const exitCode = typeof data?.exitCode === 'number' ? data.exitCode : undefined;
737
752
  if (cliCode) {
738
- return new SuperDocCliError(cliMessage ?? error.message ?? 'Command failed.', {
753
+ return this.stateDirectory.mapOperationError(new SuperDocCliError(cliMessage ?? error.message ?? 'Command failed.', {
739
754
  code: cliCode,
740
755
  details: data?.details,
741
756
  exitCode,
742
- });
757
+ }));
743
758
  }
744
759
  if (domainCode) {
745
760
  return new SuperDocCliError(error.message, {
@@ -762,6 +777,33 @@ export class HostTransport {
762
777
  details: data,
763
778
  });
764
779
  }
780
+ async waitForStoppedChildren(forceAfterTimeout = false) {
781
+ for (const [child, stopped] of this.stoppedChildren) {
782
+ let timer;
783
+ try {
784
+ await new Promise((resolve, reject) => {
785
+ const fail = () => reject(new SuperDocCliError('Host process did not stop; state has been retained.', {
786
+ code: 'HOST_DISCONNECTED',
787
+ details: { pid: child.pid },
788
+ }));
789
+ timer = setTimeout(() => {
790
+ if (!forceAfterTimeout) {
791
+ fail();
792
+ return;
793
+ }
794
+ child.kill('SIGKILL');
795
+ timer = setTimeout(fail, this.shutdownTimeoutMs);
796
+ }, this.shutdownTimeoutMs);
797
+ void stopped.then(resolve, reject);
798
+ });
799
+ this.stoppedChildren.delete(child);
800
+ }
801
+ finally {
802
+ if (timer)
803
+ clearTimeout(timer);
804
+ }
805
+ }
806
+ }
765
807
  handleDisconnect(error) {
766
808
  this.cleanupProcess(error);
767
809
  }
@@ -781,8 +823,8 @@ export class HostTransport {
781
823
  if (child) {
782
824
  if (this.stdoutDataListener)
783
825
  child.stdout.off('data', this.stdoutDataListener);
784
- child.removeAllListeners();
785
- child.kill('SIGKILL');
826
+ if (child.exitCode === null && child.signalCode === null)
827
+ child.kill('SIGKILL');
786
828
  }
787
829
  this.child = null;
788
830
  this.stdoutBuffer = '';
@@ -3,6 +3,6 @@
3
3
  // AUTO-GENERATED by scripts/embed-version.mjs — DO NOT EDIT.
4
4
  // Source of truth: package.json. Regenerated on every SDK build so the
5
5
  // SDK retains its own version identity when bundled into another package.
6
- const SDK_VERSION = '2.19.0-next.9';
6
+ const SDK_VERSION = '2.19.1-next.1';
7
7
 
8
8
  exports.SDK_VERSION = SDK_VERSION;
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.19.0-next.9";
1
+ export declare const SDK_VERSION = "2.19.1-next.1";
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED by scripts/embed-version.mjs — DO NOT EDIT.
2
2
  // Source of truth: package.json. Regenerated on every SDK build so the
3
3
  // SDK retains its own version identity when bundled into another package.
4
- export const SDK_VERSION = '2.19.0-next.9';
4
+ export const SDK_VERSION = '2.19.1-next.1';
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ var promises = require('node:fs/promises');
4
+ var os = require('node:os');
5
+ var path = require('node:path');
6
+ var node_crypto = require('node:crypto');
7
+ var errors = require('./errors.cjs');
8
+
9
+ const FILESYSTEM_CODES = new Set(['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EDQUOT', 'ENOTDIR', 'ENOENT']);
10
+ class SdkStateDirectory {
11
+ configured;
12
+ selection;
13
+ constructor(configured) {
14
+ this.configured = configured;
15
+ }
16
+ get path() {
17
+ return this.selection?.path;
18
+ }
19
+ get ownsRoot() {
20
+ return this.selection?.owned === true;
21
+ }
22
+ async prepare(env) {
23
+ if (this.configured !== undefined && (typeof this.configured !== 'string' || !this.configured.trim())) {
24
+ throw new errors.SuperDocCliError('stateDir must be a non-empty path.', { code: 'INVALID_ARGUMENT' });
25
+ }
26
+ if (this.selection) {
27
+ await this.check(this.selection);
28
+ return this.selection.path;
29
+ }
30
+ const explicit = this.configured ?? (env.SUPERDOC_CLI_STATE_DIR || undefined);
31
+ if (explicit !== undefined) {
32
+ const selection = {
33
+ path: path.resolve(explicit),
34
+ source: this.configured !== undefined ? 'stateDir' : 'SUPERDOC_CLI_STATE_DIR',
35
+ owned: false,
36
+ };
37
+ await this.check(selection);
38
+ this.selection = selection;
39
+ return selection.path;
40
+ }
41
+ let home = process.platform === 'win32' ? env.USERPROFILE : env.HOME;
42
+ if (!home) {
43
+ try {
44
+ home = os.userInfo().homedir;
45
+ }
46
+ catch {
47
+ /* A container UID need not have a passwd entry. */
48
+ }
49
+ }
50
+ if (home) {
51
+ const selection = { path: path.join(home, '.superdoc-cli', 'state', 'v1'), source: 'home', owned: false };
52
+ try {
53
+ if ((await promises.stat(home)).isDirectory()) {
54
+ await this.probe(selection.path);
55
+ this.selection = selection;
56
+ return selection.path;
57
+ }
58
+ }
59
+ catch {
60
+ /* An unusable implicit home may use private temporary state. */
61
+ }
62
+ }
63
+ const temporary = process.platform === 'win32'
64
+ ? env.TEMP || env.TMP || (env.SystemRoot ? path.join(env.SystemRoot, 'temp') : os.tmpdir())
65
+ : env.TMPDIR || env.TMP || env.TEMP || '/tmp';
66
+ let allocated;
67
+ try {
68
+ allocated = await promises.mkdtemp(path.join(path.resolve(temporary), 'superdoc-sdk-state-'));
69
+ const selection = { path: allocated, source: 'temporary', owned: true };
70
+ await this.probe(allocated);
71
+ this.selection = selection;
72
+ return allocated;
73
+ }
74
+ catch (error) {
75
+ if (allocated)
76
+ await promises.rm(allocated, { recursive: true, force: true });
77
+ throw this.failure({ path: path.resolve(temporary), source: 'temporary', owned: false }, error);
78
+ }
79
+ }
80
+ async dispose() {
81
+ const selection = this.selection;
82
+ if (!selection)
83
+ return;
84
+ if (selection.owned)
85
+ await promises.rm(selection.path, { recursive: true, force: true });
86
+ this.selection = undefined;
87
+ }
88
+ mapOperationError(error) {
89
+ const selection = this.selection;
90
+ const code = error.message.match(/\b(E[A-Z]+)\b/)?.[1];
91
+ if (!selection || !code || !FILESYSTEM_CODES.has(code) || !error.message.includes(selection.path))
92
+ return error;
93
+ return this.failure(selection, { code });
94
+ }
95
+ async check(selection) {
96
+ try {
97
+ await this.probe(selection.path);
98
+ }
99
+ catch (error) {
100
+ throw this.failure(selection, error);
101
+ }
102
+ }
103
+ async probe(path$1) {
104
+ const contexts = path.join(path$1, 'contexts');
105
+ await promises.mkdir(contexts, { recursive: true, mode: 0o700 });
106
+ const probe = path.join(contexts, '.sdk-write-' + node_crypto.randomUUID());
107
+ const handle = await promises.open(probe, 'wx', 0o600);
108
+ try {
109
+ await handle.writeFile('state');
110
+ }
111
+ finally {
112
+ await handle.close();
113
+ await promises.rm(probe, { force: true });
114
+ }
115
+ }
116
+ failure(selection, error) {
117
+ const fsCode = error?.code;
118
+ return new errors.SuperDocCliError('SuperDoc state directory is not writable: ' +
119
+ selection.path +
120
+ '. Configure stateDir or SUPERDOC_CLI_STATE_DIR with a writable directory.', {
121
+ code: 'STATE_DIRECTORY_UNWRITABLE',
122
+ details: {
123
+ path: selection.path,
124
+ source: selection.source,
125
+ fsCode,
126
+ remediation: 'Configure stateDir or SUPERDOC_CLI_STATE_DIR with a writable directory.',
127
+ },
128
+ });
129
+ }
130
+ }
131
+
132
+ exports.SdkStateDirectory = SdkStateDirectory;
@@ -0,0 +1,14 @@
1
+ import { SuperDocCliError } from './errors.js';
2
+ export declare class SdkStateDirectory {
3
+ private readonly configured;
4
+ private selection;
5
+ constructor(configured: string | undefined);
6
+ get path(): string | undefined;
7
+ get ownsRoot(): boolean;
8
+ prepare(env: NodeJS.ProcessEnv): Promise<string>;
9
+ dispose(): Promise<void>;
10
+ mapOperationError(error: SuperDocCliError): SuperDocCliError;
11
+ private check;
12
+ private probe;
13
+ private failure;
14
+ }
@@ -0,0 +1,127 @@
1
+ import { mkdir, mkdtemp, open, rm, stat } from 'node:fs/promises';
2
+ import { tmpdir, userInfo } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { SuperDocCliError } from './errors.js';
6
+ const FILESYSTEM_CODES = new Set(['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EDQUOT', 'ENOTDIR', 'ENOENT']);
7
+ export class SdkStateDirectory {
8
+ configured;
9
+ selection;
10
+ constructor(configured) {
11
+ this.configured = configured;
12
+ }
13
+ get path() {
14
+ return this.selection?.path;
15
+ }
16
+ get ownsRoot() {
17
+ return this.selection?.owned === true;
18
+ }
19
+ async prepare(env) {
20
+ if (this.configured !== undefined && (typeof this.configured !== 'string' || !this.configured.trim())) {
21
+ throw new SuperDocCliError('stateDir must be a non-empty path.', { code: 'INVALID_ARGUMENT' });
22
+ }
23
+ if (this.selection) {
24
+ await this.check(this.selection);
25
+ return this.selection.path;
26
+ }
27
+ const explicit = this.configured ?? (env.SUPERDOC_CLI_STATE_DIR || undefined);
28
+ if (explicit !== undefined) {
29
+ const selection = {
30
+ path: resolve(explicit),
31
+ source: this.configured !== undefined ? 'stateDir' : 'SUPERDOC_CLI_STATE_DIR',
32
+ owned: false,
33
+ };
34
+ await this.check(selection);
35
+ this.selection = selection;
36
+ return selection.path;
37
+ }
38
+ let home = process.platform === 'win32' ? env.USERPROFILE : env.HOME;
39
+ if (!home) {
40
+ try {
41
+ home = userInfo().homedir;
42
+ }
43
+ catch {
44
+ /* A container UID need not have a passwd entry. */
45
+ }
46
+ }
47
+ if (home) {
48
+ const selection = { path: join(home, '.superdoc-cli', 'state', 'v1'), source: 'home', owned: false };
49
+ try {
50
+ if ((await stat(home)).isDirectory()) {
51
+ await this.probe(selection.path);
52
+ this.selection = selection;
53
+ return selection.path;
54
+ }
55
+ }
56
+ catch {
57
+ /* An unusable implicit home may use private temporary state. */
58
+ }
59
+ }
60
+ const temporary = process.platform === 'win32'
61
+ ? env.TEMP || env.TMP || (env.SystemRoot ? join(env.SystemRoot, 'temp') : tmpdir())
62
+ : env.TMPDIR || env.TMP || env.TEMP || '/tmp';
63
+ let allocated;
64
+ try {
65
+ allocated = await mkdtemp(join(resolve(temporary), 'superdoc-sdk-state-'));
66
+ const selection = { path: allocated, source: 'temporary', owned: true };
67
+ await this.probe(allocated);
68
+ this.selection = selection;
69
+ return allocated;
70
+ }
71
+ catch (error) {
72
+ if (allocated)
73
+ await rm(allocated, { recursive: true, force: true });
74
+ throw this.failure({ path: resolve(temporary), source: 'temporary', owned: false }, error);
75
+ }
76
+ }
77
+ async dispose() {
78
+ const selection = this.selection;
79
+ if (!selection)
80
+ return;
81
+ if (selection.owned)
82
+ await rm(selection.path, { recursive: true, force: true });
83
+ this.selection = undefined;
84
+ }
85
+ mapOperationError(error) {
86
+ const selection = this.selection;
87
+ const code = error.message.match(/\b(E[A-Z]+)\b/)?.[1];
88
+ if (!selection || !code || !FILESYSTEM_CODES.has(code) || !error.message.includes(selection.path))
89
+ return error;
90
+ return this.failure(selection, { code });
91
+ }
92
+ async check(selection) {
93
+ try {
94
+ await this.probe(selection.path);
95
+ }
96
+ catch (error) {
97
+ throw this.failure(selection, error);
98
+ }
99
+ }
100
+ async probe(path) {
101
+ const contexts = join(path, 'contexts');
102
+ await mkdir(contexts, { recursive: true, mode: 0o700 });
103
+ const probe = join(contexts, '.sdk-write-' + randomUUID());
104
+ const handle = await open(probe, 'wx', 0o600);
105
+ try {
106
+ await handle.writeFile('state');
107
+ }
108
+ finally {
109
+ await handle.close();
110
+ await rm(probe, { force: true });
111
+ }
112
+ }
113
+ failure(selection, error) {
114
+ const fsCode = error?.code;
115
+ return new SuperDocCliError('SuperDoc state directory is not writable: ' +
116
+ selection.path +
117
+ '. Configure stateDir or SUPERDOC_CLI_STATE_DIR with a writable directory.', {
118
+ code: 'STATE_DIRECTORY_UNWRITABLE',
119
+ details: {
120
+ path: selection.path,
121
+ source: selection.source,
122
+ fsCode,
123
+ remediation: 'Configure stateDir or SUPERDOC_CLI_STATE_DIR with a writable directory.',
124
+ },
125
+ });
126
+ }
127
+ }
@@ -45,6 +45,8 @@ export interface UserIdentity {
45
45
  email?: string;
46
46
  }
47
47
  export interface SuperDocClientOptions {
48
+ /** Writable host state root. Takes precedence over SUPERDOC_CLI_STATE_DIR; persists after disposal. */
49
+ stateDir?: string;
48
50
  env?: Record<string, string | undefined>;
49
51
  /**
50
52
  * Standalone document-host entry to use instead of the embedded CLI.