chore: import upstream snapshot with attribution
Publish SDK (PyPI) / publish (push) Has been cancelled
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (windows-latest) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/sdk) (push) Has been cancelled
Publish SDK (npm) / publish (officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (macos-latest) (push) Has been cancelled
SDK smoke / smoke (ubuntu-latest) (push) Has been cancelled
Skill parity / diff (push) Has been cancelled
Publish SDK (PyPI) / publish (push) Has been cancelled
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (windows-latest) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/sdk) (push) Has been cancelled
Publish SDK (npm) / publish (officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (macos-latest) (push) Has been cancelled
SDK smoke / smoke (ubuntu-latest) (push) Has been cancelled
Skill parity / diff (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# @officecli/sdk
|
||||
|
||||
A thin **async** Node.js client over [officecli](https://github.com/iOfficeAI/OfficeCLI)'s
|
||||
resident pipe. It does one thing: forward a command to the running resident and
|
||||
hand back the response. There is no second vocabulary to learn — a command is the
|
||||
same object you'd put in an officecli `batch` list.
|
||||
|
||||
```bash
|
||||
npm install @officecli/sdk
|
||||
```
|
||||
|
||||
Installing the SDK pulls `@officecli/officecli`, which bundles an auto-updating
|
||||
native binary — so the CLI comes with it and you don't manage it separately. If
|
||||
the binary is ever missing, the SDK provisions it on first use (downloads the
|
||||
bundled signed binary, or falls back to the official installer).
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const oc = require('@officecli/sdk');
|
||||
|
||||
const doc = await oc.create('report.xlsx', ['--force']);
|
||||
try {
|
||||
await doc.send({ command: 'set', path: '/Sheet1/A1', props: { text: 'Hello' } });
|
||||
const a1 = await doc.send({ command: 'get', path: '/Sheet1/A1' }); // → envelope object
|
||||
console.log(a1);
|
||||
|
||||
// Many writes in one round-trip:
|
||||
await doc.batch([
|
||||
{ command: 'set', path: '/Sheet1/B1', props: { text: '42' } },
|
||||
{ command: 'set', path: '/Sheet1/C1', props: { text: 'world' } },
|
||||
]);
|
||||
} finally {
|
||||
await doc.close(); // flushes to disk
|
||||
}
|
||||
```
|
||||
|
||||
On Node ≥ 24 you can use `await using` and skip the explicit close:
|
||||
|
||||
```js
|
||||
await using doc = await oc.open('existing.xlsx');
|
||||
await doc.send({ command: 'get', path: '/body/p[1]' });
|
||||
```
|
||||
|
||||
## Two surfaces
|
||||
|
||||
- **bootstrap** (infrequent): `create()` / `open()` spawn one CLI process.
|
||||
- **hot path**: `send()` / `batch()` are pure pipe round-trips, no per-command
|
||||
process spawn.
|
||||
|
||||
## API
|
||||
|
||||
- `await create(path, args?, options?)` → `Document` — make a new file. Extra CLI
|
||||
flags pass through verbatim (`['--force']`, `['--type', 'docx']`).
|
||||
- `await open(path, options?)` → `Document` — open an existing file.
|
||||
- `Document.send(item, asJson = true, timeoutMs?)` — forward one command.
|
||||
`asJson = false` requests plain-text output (view/raw/dump).
|
||||
- `Document.batch(items, { force = true, stopOnError = false, timeoutMs? })`.
|
||||
- `Document.alive(timeoutMs?)` — is a resident serving this file?
|
||||
- `Document.close()` — stop the resident (flushes to disk).
|
||||
- `install()` — run the official installer (unix only).
|
||||
|
||||
`options`: `{ binary?, timeoutMs?, autoInstall? }`. Pass `binary` to point at a
|
||||
specific officecli; `autoInstall: false` to disable provisioning a missing CLI.
|
||||
|
||||
## Errors vs business outcomes
|
||||
|
||||
Transport/process failures throw `OfficeCliError`. Business outcomes are **not**
|
||||
exceptions — they live in the returned envelope's `success` field, exactly like
|
||||
the CLI's exit code. Check `result.success` yourself.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```js
|
||||
// Owner — close on exit:
|
||||
const d = await oc.open(f);
|
||||
try { /* ... */ } finally { await d.close(); }
|
||||
|
||||
// Borrow — leave a resident another program owns running:
|
||||
const d = await oc.open(f);
|
||||
await d.send(/* ... */); // no close()
|
||||
```
|
||||
|
||||
A dead resident is transparently restarted and the command retried once. An
|
||||
alive-but-busy pipe raises `OfficeCliError` (retry, or `close()` and reopen) —
|
||||
the SDK never bypasses a live resident, which would race its save.
|
||||
|
||||
Licensed under Apache-2.0.
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
// Minimal end-to-end demo: create a workbook, set + read a cell, close.
|
||||
// node demo.js [path.xlsx]
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const oc = require('./index.js');
|
||||
|
||||
async function main() {
|
||||
const file = process.argv[2] || path.join(os.tmpdir(), `officecli-demo-${process.pid}.xlsx`);
|
||||
|
||||
// create() makes the file and binds to the resident it auto-starts.
|
||||
const doc = await oc.create(file, ['--force']);
|
||||
try {
|
||||
// One write.
|
||||
await doc.send({ command: 'set', path: '/Sheet1/A1', props: { text: 'Hello from Node' } });
|
||||
|
||||
// Read it back — get --json returns the envelope as a JS object.
|
||||
const got = await doc.send({ command: 'get', path: '/Sheet1/A1' });
|
||||
console.log('A1 =', JSON.stringify(got));
|
||||
|
||||
// Many writes in one round-trip.
|
||||
await doc.batch([
|
||||
{ command: 'set', path: '/Sheet1/B1', props: { text: '42' } },
|
||||
{ command: 'set', path: '/Sheet1/C1', props: { text: 'world' } },
|
||||
]);
|
||||
|
||||
console.log('B1 =', JSON.stringify(await doc.send({ command: 'get', path: '/Sheet1/B1' })));
|
||||
} finally {
|
||||
// close() flushes the resident's in-memory doc to disk.
|
||||
await doc.close();
|
||||
}
|
||||
console.log('wrote', file);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
// Type definitions for @officecli/sdk
|
||||
|
||||
/** A single command in officecli's batch-item shape. `command` (or `op`) picks
|
||||
* the command, `props` becomes the property map, and every other key is
|
||||
* forwarded verbatim as a command argument. */
|
||||
export interface BatchItem {
|
||||
command?: string;
|
||||
op?: string;
|
||||
path?: string;
|
||||
parent?: string;
|
||||
type?: string;
|
||||
index?: number | string;
|
||||
after?: string;
|
||||
before?: string;
|
||||
to?: string;
|
||||
selector?: string;
|
||||
props?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Parsed result: the JSON envelope (object/array) for --json commands, or raw
|
||||
* text for content commands (view/raw/dump). */
|
||||
export type Result = Record<string, unknown> | unknown[] | string;
|
||||
|
||||
export interface OpenOptions {
|
||||
/** CLI binary name or absolute path. Default "officecli". */
|
||||
binary?: string;
|
||||
/** Command-delivery timeout in ms (connect + retries); the reply read blocks. Default 30000. */
|
||||
timeoutMs?: number;
|
||||
/** Actively install/download the CLI if missing (bundled binary, then install.sh). Default true. */
|
||||
autoInstall?: boolean;
|
||||
}
|
||||
|
||||
export interface BatchOptions {
|
||||
force?: boolean;
|
||||
stopOnError?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Raised on transport/process failure (could not reach the resident). Business
|
||||
* outcomes are NOT errors — they live in the returned envelope's `success` field. */
|
||||
export class OfficeCliError extends Error {
|
||||
code: number;
|
||||
constructor(code: number, msg: string);
|
||||
}
|
||||
|
||||
/** A live handle to a resident serving one document. */
|
||||
export class Document {
|
||||
readonly path: string;
|
||||
/** Forward ONE batch-shaped command; returns the parsed envelope or raw text. */
|
||||
send(item: BatchItem, asJson?: boolean, timeoutMs?: number): Promise<Result>;
|
||||
/** Forward a LIST of commands in one round-trip (the fast path for many writes). */
|
||||
batch(items: BatchItem[], options?: BatchOptions): Promise<Result>;
|
||||
/** True iff a resident is alive AND serving this file. */
|
||||
alive(timeoutMs?: number): Promise<boolean>;
|
||||
/** Stop the resident (flushes to disk on shutdown). */
|
||||
close(): Promise<Result>;
|
||||
[Symbol.asyncDispose](): Promise<void>;
|
||||
}
|
||||
|
||||
/** Create a blank Office document and return a live handle. */
|
||||
export function create(filePath: string, args?: string[], options?: OpenOptions): Promise<Document>;
|
||||
|
||||
/** Open an existing document and return a live handle. */
|
||||
export function open(filePath: string, options?: OpenOptions): Promise<Document>;
|
||||
|
||||
/** Install the officecli CLI via its official installer (unix only). */
|
||||
export function install(): void;
|
||||
|
||||
/** [main, ping] pipe addresses for a document path (debug helper). */
|
||||
export function pipePaths(filePath: string): [string, string];
|
||||
@@ -0,0 +1,644 @@
|
||||
'use strict';
|
||||
/*
|
||||
* officecli — a thin Node.js shell over officecli's resident pipe.
|
||||
*
|
||||
* Node port of sdk/python/officecli.py. Same one job: forward a command to the
|
||||
* running resident over its named pipe and hand back the response. There is NO
|
||||
* second vocabulary: a command is the same object you'd put in an officecli
|
||||
* `batch` list — e.g. {command:"set", path:"/Sheet1/A1", props:{text:"Hello"}}.
|
||||
* `send` forwards one; `batch` forwards many in a single round-trip.
|
||||
*
|
||||
* Two surfaces, by design:
|
||||
* - bootstrap (infrequent): create() / open() spawn ONE CLI process — a file
|
||||
* that isn't open yet has no resident to talk to.
|
||||
* - everything else (the hot path): send() / batch() are pure pipe
|
||||
* round-trips, no per-command process spawn.
|
||||
*
|
||||
* const oc = require('@officecli/sdk');
|
||||
* const doc = await oc.create('report.xlsx', ['--force']);
|
||||
* try {
|
||||
* await doc.send({ command: 'set', path: '/Sheet1/A1', props: { text: 'Hello' } });
|
||||
* console.log(await doc.send({ command: 'get', path: '/Sheet1/A1' }));
|
||||
* } finally {
|
||||
* await doc.close();
|
||||
* }
|
||||
* // or `await using doc = await oc.open('existing.xlsx')` on Node >= 24.
|
||||
*
|
||||
* Protocol (matches ResidentServer.cs / ResidentClient.cs):
|
||||
* - pipe name : officecli-<SHA256(fullpath)[:16] uppercase>;
|
||||
* fullpath upper-cased on macOS/Windows, left as-is on Linux.
|
||||
* - unix path : $TMPDIR/CoreFxPipe_<name> (+ "-ping"); $TMPDIR else /tmp
|
||||
* - win path : \\.\pipe\<name> (+ "-ping")
|
||||
* - framing : one request line + one response line, UTF-8, '\n' terminated;
|
||||
* one connection == one command. The reply may carry a UTF-8 BOM
|
||||
* (unix StreamWriter) and a trailing '\r' — both stripped here.
|
||||
* - request : PascalCase {"Command","Args","Props","Json"}
|
||||
* - response : {"ExitCode","Stdout","Stderr"}
|
||||
*/
|
||||
|
||||
const os = require('os');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const IS_WIN = process.platform === 'win32';
|
||||
const IS_MAC = process.platform === 'darwin';
|
||||
|
||||
// Mirror officecli's TryResident busy-delivery policy (CommandBuilder.cs): a
|
||||
// generous connect timeout + a few retries with backoff, applied identically to
|
||||
// every command. The reply read itself blocks (no timeout) — like officecli's
|
||||
// PipeReadLine — trusting the resident to answer once our turn comes up in its
|
||||
// serialized queue. Retries only re-attempt the CONNECT (before the command
|
||||
// executes), so re-sending is safe even for mutations: there is no "read timed
|
||||
// out, resend" path that could double-apply.
|
||||
const BUSY_CONNECT_TIMEOUT_MS = 30000; // = ResidentBusyConnectTimeoutMs
|
||||
const BUSY_MAX_RETRIES = 3; // = ResidentBusyMaxRetries
|
||||
// = CommandBuilder's DefaultOpenIdleSeconds. open() upgrades a reused resident
|
||||
// (which create() may have auto-started with a short 60s timeout) to the 12min
|
||||
// interactive window, so a long session over an SDK handle isn't cut short.
|
||||
const OPEN_IDLE_SECONDS = 12 * 60;
|
||||
|
||||
// Installer scripts: the d.officecli.ai mirror is primary; GitHub raw is only a
|
||||
// fallback (same order as install.sh / install-binary.js). The mirror is
|
||||
// Cloudflare-fronted and reachable where raw.githubusercontent.com may be
|
||||
// rate-limited or blocked.
|
||||
const INSTALL_SH_MIRROR = 'https://d.officecli.ai/install.sh';
|
||||
const INSTALL_SH_GITHUB = 'https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh';
|
||||
const INSTALL_PS1_MIRROR = 'https://d.officecli.ai/install.ps1';
|
||||
const INSTALL_PS1_GITHUB = 'https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1';
|
||||
const MISSING_CLI =
|
||||
"officecli CLI not found: {bin} is not on PATH nor in the default install " +
|
||||
'location (~/.local/bin, or %LOCALAPPDATA%\\OfficeCLI on Windows). This SDK only ' +
|
||||
'forwards commands to the officecli binary, which must be installed separately. Install it:\n' +
|
||||
' node -e "require(\'@officecli/sdk\').install()" # runs the official installer\n' +
|
||||
' # or: curl -fsSL ' + INSTALL_SH_MIRROR + ' | bash\n' +
|
||||
' # (npm i @officecli/sdk already pulls @officecli/officecli, which bundles the binary)\n' +
|
||||
'Already installed elsewhere? pass { binary: "/path/to/officecli" }.';
|
||||
|
||||
/**
|
||||
* Raised on transport/process failure (could not reach the resident). Business
|
||||
* outcomes are NOT exceptions — they live in the returned envelope's `success`
|
||||
* field, same as the CLI's exit code.
|
||||
*/
|
||||
class OfficeCliError extends Error {
|
||||
constructor(code, msg) {
|
||||
super(`[exit ${code}] ${msg}`);
|
||||
this.name = 'OfficeCliError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pipe address
|
||||
function dotnetTempDir() {
|
||||
// Mirror .NET Path.GetTempPath() on Unix exactly: $TMPDIR else /tmp.
|
||||
return process.env.TMPDIR || '/tmp';
|
||||
}
|
||||
|
||||
// Match the path officecli's resident hashes into the pipe name. On Windows it
|
||||
// derives the name from GetFullPath, which expands 8.3 short components (e.g.
|
||||
// RUNNER~1, or any user name > 8 chars under %TEMP%) to their long form.
|
||||
// path.resolve does NOT expand 8.3, so a short path would hash to a different
|
||||
// pipe and every connect fails with ENOENT — hence realpath, which does expand
|
||||
// it. realpath needs the file to exist; fall back to the resolved path when it
|
||||
// doesn't (e.g. pre-create). realpath ALSO resolves symlinks/junctions, which
|
||||
// GetFullPath does not; harmless here because we hand this resolved path to the
|
||||
// resident, so the server's GetFullPath sees the already-resolved string and
|
||||
// both sides hash the same thing. Windows only — on Linux/macOS officecli uses
|
||||
// GetFullPath (no symlink resolution), so realpath would diverge there
|
||||
// (e.g. /tmp → /private/tmp on macOS).
|
||||
function canonicalPath(filePath) {
|
||||
const resolved = path.resolve(filePath);
|
||||
if (IS_WIN) {
|
||||
try { return fs.realpathSync.native(resolved); } catch (_) { /* not there yet */ }
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** [main, ping] pipe addresses for a document path. Exposed for debugging. */
|
||||
function pipePaths(filePath) {
|
||||
let full = canonicalPath(filePath);
|
||||
if (IS_MAC || IS_WIN) full = full.toUpperCase(); // Linux: case-sensitive, no upper
|
||||
const h = crypto.createHash('sha256').update(full, 'utf8').digest('hex').toUpperCase().slice(0, 16);
|
||||
const name = `officecli-${h}`;
|
||||
if (IS_WIN) return [`\\\\.\\pipe\\${name}`, `\\\\.\\pipe\\${name}-ping`];
|
||||
const base = path.join(dotnetTempDir(), `CoreFxPipe_${name}`);
|
||||
return [base, base + '-ping'];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- transport
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// One attempt: bound the CONNECT, then block on the reply (no read timeout) —
|
||||
// exactly like officecli's TrySend (Connect(timeout) + blocking PipeReadLine).
|
||||
function sendOnce(sockPath, line, connectTimeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const chunks = [];
|
||||
const socket = net.createConnection(sockPath);
|
||||
const connTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
reject(new Error(`connect timed out after ${connectTimeoutMs}ms`));
|
||||
}, connectTimeoutMs);
|
||||
const fail = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(connTimer);
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
};
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(connTimer); // connected: stop bounding; reply read blocks
|
||||
socket.write(line);
|
||||
});
|
||||
socket.on('data', (d) => {
|
||||
chunks.push(d);
|
||||
// Line protocol: the reply is one '\n'-terminated line.
|
||||
if (d.length && d[d.length - 1] === 0x0a) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(connTimer);
|
||||
socket.end();
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
socket.once('end', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(connTimer);
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
socket.once('error', fail);
|
||||
});
|
||||
}
|
||||
|
||||
function decodeLine(raw) {
|
||||
let text = raw.toString('utf8');
|
||||
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // strip UTF-8 BOM (unix StreamWriter)
|
||||
if (text.charCodeAt(text.length - 1) === 0x0a) text = text.slice(0, -1); // drop the '\n' terminator
|
||||
if (text.charCodeAt(text.length - 1) === 0x0d) text = text.slice(0, -1); // and a trailing '\r'
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one request, mirroring officecli's TrySend: bounded connect + a few
|
||||
* retries with backoff, then a blocking read. A retry only re-attempts the
|
||||
* connect (before the command runs), so it never double-applies a mutation. If
|
||||
* the command still can't be delivered, raise a busy/unresponsive error — never
|
||||
* fall back to touching the file directly (that would race the resident).
|
||||
*
|
||||
* `maxRetries` overrides the busy-retry count. Liveness probes (serves) pass 0
|
||||
* so a missing/stale pipe fails FAST instead of sleeping through the backoff.
|
||||
*/
|
||||
async function rpc(sockPath, req, connectTimeoutMs = BUSY_CONNECT_TIMEOUT_MS, maxRetries = BUSY_MAX_RETRIES) {
|
||||
const line = Buffer.from(JSON.stringify(req) + '\n', 'utf8');
|
||||
let raw = null;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
raw = await sendOnce(sockPath, line, connectTimeoutMs);
|
||||
break;
|
||||
} catch (e) {
|
||||
// Connect/socket error only — the command never ran, so a retry is safe.
|
||||
if (attempt >= maxRetries) {
|
||||
throw new OfficeCliError(
|
||||
-1,
|
||||
'resident is running but the command could not be delivered ' +
|
||||
`(pipe busy or unresponsive); retry, or close and reopen [${e.message}]`
|
||||
);
|
||||
}
|
||||
await sleep(50 * (attempt + 1)); // = TrySend's 50*(n+1)ms backoff
|
||||
}
|
||||
}
|
||||
const text = decodeLine(raw);
|
||||
if (!text.trim()) {
|
||||
// Empty/closed reply: the resident accepted the connection but closed
|
||||
// without a complete response (e.g. crashed mid-serve). We refuse to
|
||||
// re-send — the command may already have been APPLIED before the resident
|
||||
// died, so re-sending would double-apply a non-idempotent op — and raise
|
||||
// instead. _cmd's recovery then restarts a dead resident and retries once.
|
||||
throw new OfficeCliError(
|
||||
-1,
|
||||
'resident closed the connection without a response ' +
|
||||
'(it may have crashed mid-command); retry, or close and reopen'
|
||||
);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function parseEnvelope(resp) {
|
||||
// Return the useful payload: the parsed JSON envelope (object/array) if Stdout
|
||||
// is JSON, otherwise the raw Stdout text ("" when empty). Accept ONLY
|
||||
// object/array — a bare JSON scalar ("42", "true", "null", a quoted string)
|
||||
// stays text, or the caller can't tell literal text "42" from the number 42.
|
||||
const out = (resp && resp.Stdout) || '';
|
||||
let v;
|
||||
try {
|
||||
v = JSON.parse(out);
|
||||
} catch (_) {
|
||||
return out;
|
||||
}
|
||||
return v !== null && typeof v === 'object' ? v : out;
|
||||
}
|
||||
|
||||
function strMap(o) {
|
||||
// Drop null/undefined values (omit), and stringify the rest — the resident's
|
||||
// Args/Props are Dictionary<string,string>. A value set to null means "don't
|
||||
// send it", not "send empty string"; pass "" for an explicit empty value.
|
||||
const r = {};
|
||||
for (const k of Object.keys(o)) {
|
||||
const v = o[k];
|
||||
if (v !== null && v !== undefined) r[k] = String(v);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
async function serves(pingPath, fullPath, timeoutMs = 1000) {
|
||||
// Is a resident alive on `pingPath` AND serving `fullPath`? Probes the
|
||||
// always-responsive `-ping` pipe (officecli's TryConnect): it answers even
|
||||
// while the MAIN pipe is busy. Single-shot (maxRetries=0): a probe should fail
|
||||
// fast, not sit through the busy-retry backoff.
|
||||
let resp;
|
||||
try {
|
||||
resp = await rpc(pingPath, { Command: '__ping__' }, timeoutMs, 0);
|
||||
} catch (e) {
|
||||
if (e instanceof OfficeCliError) return false;
|
||||
throw e;
|
||||
}
|
||||
const served = ((resp && resp.Stdout) || '').trim(); // ping echoes the served path
|
||||
if (!served) return false;
|
||||
const a = path.resolve(served);
|
||||
return a === fullPath || ((IS_MAC || IS_WIN) && a.toLowerCase() === fullPath.toLowerCase());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- binary resolution
|
||||
function installDirCandidate(name) {
|
||||
// Where install.sh / install.ps1 drop the binary: ~/.local/bin on macOS/Linux,
|
||||
// %LOCALAPPDATA%\OfficeCLI on Windows. PATH-miss fallback only.
|
||||
if (IS_WIN) {
|
||||
const base = process.env.LOCALAPPDATA;
|
||||
if (!base) return null;
|
||||
const exe = name.toLowerCase().endsWith('.exe') ? name : name + '.exe';
|
||||
return path.join(base, 'OfficeCLI', exe);
|
||||
}
|
||||
return path.join(os.homedir(), '.local', 'bin', name);
|
||||
}
|
||||
|
||||
function whichOnPath(name) {
|
||||
const dirs = (process.env.PATH || '').split(path.delimiter);
|
||||
const cands = IS_WIN ? [name, name + '.exe', name + '.cmd'] : [name];
|
||||
for (const dir of dirs) {
|
||||
if (!dir) continue;
|
||||
for (const c of cands) {
|
||||
const p = path.join(dir, c);
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return p;
|
||||
} catch (_) {
|
||||
/* keep looking */
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bundledBinary() {
|
||||
// If the @officecli/officecli installer package is installed (it is a
|
||||
// dependency), prefer its vendored, auto-updating binary. Returns the path if
|
||||
// it is present on disk, else null. require is wrapped so the SDK still works
|
||||
// as a pure thin client when the dependency was omitted.
|
||||
try {
|
||||
const cli = require('@officecli/officecli');
|
||||
const p = cli.binaryPath();
|
||||
return fs.existsSync(p) ? p : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBinary(binary) {
|
||||
// Order: explicit path (has a separator) is trusted as-is; then the bundled
|
||||
// installer-package binary; then PATH; then the official installer's known
|
||||
// location. Idempotent — an already-resolved absolute path passes through.
|
||||
if (binary.includes(path.sep) || binary.includes('/')) return binary;
|
||||
if (binary === 'officecli') {
|
||||
const bundled = bundledBinary();
|
||||
if (bundled) return bundled;
|
||||
}
|
||||
const found = whichOnPath(binary);
|
||||
if (found) return found;
|
||||
const cand = installDirCandidate(binary);
|
||||
if (cand) {
|
||||
try {
|
||||
fs.accessSync(cand, fs.constants.X_OK);
|
||||
return cand;
|
||||
} catch (_) {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return binary; // give up; runCli raises the helpful error
|
||||
}
|
||||
|
||||
async function ensureCliBinary(binary, autoInstall) {
|
||||
// Async binary resolution for the entry points (open/create), able to ACTIVELY
|
||||
// provision a missing CLI. Order: explicit path is trusted as-is; then the
|
||||
// bundled installer package (download its binary if not yet present — this is
|
||||
// the package's own signed download, not a surprise); then PATH; then the
|
||||
// installer's known location; finally, if autoInstall, run the official
|
||||
// install.sh. Returns the resolved path (or the bare name, so runCli raises
|
||||
// the helpful MISSING_CLI error if everything failed).
|
||||
if (binary.includes(path.sep) || binary.includes('/')) return binary; // explicit: trust caller
|
||||
|
||||
// Accept the first candidate that actually RUNS (`--version` exit 0), in
|
||||
// priority order: the bundled (auto-updating) binary, then PATH, then the
|
||||
// installer's known location. Probing — not mere file existence — means a
|
||||
// present-but-broken binary is skipped, and a working officecli is never
|
||||
// shadowed by a needless install.
|
||||
const candidates = [];
|
||||
if (binary === 'officecli') {
|
||||
try {
|
||||
const p = require('@officecli/officecli').binaryPath();
|
||||
if (fs.existsSync(p)) candidates.push(p);
|
||||
} catch (_) { /* dependency absent */ }
|
||||
}
|
||||
const onPath = whichOnPath(binary);
|
||||
if (onPath) candidates.push(onPath);
|
||||
const cand = installDirCandidate(binary);
|
||||
if (cand) candidates.push(cand);
|
||||
|
||||
for (const c of candidates) {
|
||||
if (probeVersion(c)) return c;
|
||||
}
|
||||
|
||||
// Nothing usable found anywhere — provision it (only for the default name).
|
||||
if (autoInstall && binary === 'officecli') {
|
||||
process.stderr.write('[officecli] CLI not found — installing from d.officecli.ai …\n');
|
||||
try {
|
||||
const cli = require('@officecli/officecli');
|
||||
await cli.ensureBinary(); // bundled package's own signed download
|
||||
const p = cli.binaryPath();
|
||||
if (probeVersion(p)) return p;
|
||||
} catch (_) { /* fall through to the official installer */ }
|
||||
install(); // install.sh (unix) / install.ps1 (Windows)
|
||||
const after = installDirCandidate('officecli');
|
||||
if (after && probeVersion(after)) return after;
|
||||
}
|
||||
return binary; // give up → runCli raises the helpful MISSING_CLI
|
||||
}
|
||||
|
||||
// Quote one token for a cmd.exe command line: ALWAYS wrap in double quotes,
|
||||
// doubling any embedded quote. Unconditional quoting (rather than "only when it
|
||||
// looks dangerous") is deliberate — it removes any reliance on enumerating every
|
||||
// cmd metacharacter. A double-quoted token cannot be re-tokenized by cmd.exe, so
|
||||
// & | < > ^ ( ) " are inert, and a %VAR% expansion stays *inside* the quotes and
|
||||
// thus can never inject a command separator (at worst the argument is mangled —
|
||||
// there is no command-line escape for %, a documented cmd.exe limitation). This
|
||||
// matters because the binary path / args may be untrusted when the SDK is driven
|
||||
// by a server. cmd /s strips the outer wrapper, leaving these per-token quotes.
|
||||
function quoteForCmd(s) {
|
||||
return `"${String(s).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function spawnCli(binary, argv, extraOpts) {
|
||||
let cmd = binary;
|
||||
let args = argv;
|
||||
const opts = { encoding: 'utf8', ...extraOpts };
|
||||
if (IS_WIN && /\.(cmd|bat)$/i.test(binary)) {
|
||||
// Node refuses to spawn a .cmd/.bat directly without a shell since
|
||||
// CVE-2024-27980 (raises EINVAL). Run it through cmd.exe ourselves,
|
||||
// quoting each token so paths with spaces survive — shell:true would
|
||||
// join the args unquoted and break on the first space. Mirrors Node's
|
||||
// own shell idiom (cmd /d /s /c "<line>" + windowsVerbatimArguments)
|
||||
// but with the per-token quoting shell:true omits.
|
||||
const line = [binary, ...argv].map(quoteForCmd).join(' ');
|
||||
cmd = process.env.ComSpec || 'cmd.exe';
|
||||
args = ['/d', '/s', '/c', `"${line}"`];
|
||||
opts.windowsVerbatimArguments = true;
|
||||
}
|
||||
return spawnSync(cmd, args, opts);
|
||||
}
|
||||
|
||||
function runCli(binary, argv) {
|
||||
const r = spawnCli(binary, argv);
|
||||
if (r.error && r.error.code === 'ENOENT') {
|
||||
throw new OfficeCliError(127, MISSING_CLI.replace('{bin}', JSON.stringify(binary)));
|
||||
}
|
||||
if (r.error) throw new OfficeCliError(-1, r.error.message);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Probe a resolved binary by running `<binary> --version`: true iff it actually
|
||||
// runs and exits 0. We accept only a WORKING officecli — a present-but-broken
|
||||
// file (wrong arch, stale/again-renamed shim, corrupt download) must not be
|
||||
// used, and conversely a working officecli on PATH must not be shadowed by a
|
||||
// needless auto-install. Output is discarded.
|
||||
function probeVersion(binPath) {
|
||||
const r = spawnCli(binPath, ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
return !r.error && r.status === 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the shell
|
||||
class Document {
|
||||
constructor(filePath, binary = 'officecli', timeoutMs = 30000) {
|
||||
// Canonical (Windows 8.3-expanded) so the pipe name AND the serves() path
|
||||
// comparison both match what the resident reports.
|
||||
this.path = canonicalPath(filePath);
|
||||
this.bin = resolveBinary(binary);
|
||||
this.timeout = timeoutMs; // connect timeout (ms); the reply read blocks
|
||||
const [main, ping] = pipePaths(this.path);
|
||||
this._main = main;
|
||||
this._ping = ping;
|
||||
this._restarting = null; // in-flight dead-resident restart (serializes callers)
|
||||
}
|
||||
|
||||
async _start() {
|
||||
// Reuse a resident already serving this file (no spawn). serves() is a real
|
||||
// liveness probe (ping + path match), so a stale/dead socket falls through
|
||||
// to `officecli open`, which replaces it via TryConnect.
|
||||
if (await serves(this._ping, this.path)) return;
|
||||
const r = runCli(this.bin, ['open', this.path]);
|
||||
if (r.status !== 0) throw new OfficeCliError(r.status == null ? -1 : r.status, r.stderr || r.stdout);
|
||||
}
|
||||
|
||||
async _cmd(command, args, props, asJson = true, timeoutMs) {
|
||||
const req = { Command: command, Json: asJson };
|
||||
if (args) req.Args = strMap(args);
|
||||
if (props !== null && props !== undefined) req.Props = strMap(props);
|
||||
const t = timeoutMs == null ? this.timeout : timeoutMs;
|
||||
try {
|
||||
return await rpc(this._main, req, t, BUSY_MAX_RETRIES);
|
||||
} catch (e) {
|
||||
if (!(e instanceof OfficeCliError)) throw e;
|
||||
// Delivery failed. Use the -ping pipe to tell DEAD from BUSY:
|
||||
// • ALIVE but main pipe unresponsive → do NOT bypass it (a second writer
|
||||
// racing the live resident loses data on its save). Re-raise.
|
||||
// • DEAD (crashed / stale socket) → restart with one `officecli open`
|
||||
// and retry ONCE. Safe across reads and mutations.
|
||||
if (await this.alive()) throw e;
|
||||
// Serialize the restart across concurrent callers sharing this Document so
|
||||
// only one spawns `officecli open` (else N-1 orphaned residents race saves).
|
||||
if (!this._restarting) {
|
||||
this._restarting = (async () => {
|
||||
if (!(await this.alive())) await this._start();
|
||||
})().finally(() => {
|
||||
this._restarting = null;
|
||||
});
|
||||
}
|
||||
await this._restarting;
|
||||
return await rpc(this._main, req, t, BUSY_MAX_RETRIES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward ONE command in officecli's batch-item shape and return its parsed
|
||||
* result (the JSON envelope, or raw text for content commands). `item` is
|
||||
* exactly an object you'd put in a `batch` list:
|
||||
* { command: 'set', path: '/Sheet1/A1', props: { text: 'hi' } }
|
||||
* `command` (or `op`) picks the command, `props` becomes the property map, and
|
||||
* every other key is forwarded verbatim as a command argument. asJson=false
|
||||
* requests plain-text output (view/raw/dump), mirroring the CLI's --json.
|
||||
*/
|
||||
async send(item, asJson = true, timeoutMs) {
|
||||
const command = item.command || item.op;
|
||||
if (!command) throw new OfficeCliError(-1, "send(item): item needs a 'command' (or 'op') key");
|
||||
const args = {};
|
||||
for (const k of Object.keys(item)) {
|
||||
if (k !== 'command' && k !== 'op' && k !== 'props') args[k] = item[k];
|
||||
}
|
||||
return parseEnvelope(await this._cmd(command, args, item.props, asJson, timeoutMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward officecli's `batch`: apply a LIST of the same item objects as send()
|
||||
* in ONE round-trip — the fast path for many writes.
|
||||
*/
|
||||
async batch(items, { force = true, stopOnError = false, timeoutMs } = {}) {
|
||||
const args = { batchJson: JSON.stringify(items), force, stopOnError };
|
||||
return parseEnvelope(await this._cmd('batch', args, undefined, true, timeoutMs));
|
||||
}
|
||||
|
||||
// Best-effort idle-timeout upgrade over the always-responsive ping pipe.
|
||||
// Mirrors ResidentClient.SendSetIdleTimeout: a failure is non-fatal — the
|
||||
// resident keeps its original idle schedule. Single-shot (maxRetries=0).
|
||||
async _setIdleTimeout(seconds) {
|
||||
try {
|
||||
await rpc(this._ping, { Command: '__set-idle-timeout__', Args: { seconds: String(seconds) } }, this.timeout, 0);
|
||||
} catch (e) {
|
||||
if (!(e instanceof OfficeCliError)) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff a resident is alive AND serving this file (probes the -ping pipe). */
|
||||
async alive(timeoutMs = 1000) {
|
||||
return serves(this._ping, this.path, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* = `officecli close`: stop the resident. It flushes the in-memory doc to disk
|
||||
* as it shuts down, so no separate save is needed. The resident acks AFTER
|
||||
* shutting down, so a missing/empty ack still means "closed"; a real shutdown
|
||||
* error is a non-empty response and surfaces through the return value.
|
||||
*/
|
||||
async close() {
|
||||
try {
|
||||
return parseEnvelope(await rpc(this._ping, { Command: '__close__' }, this.timeout));
|
||||
} catch (e) {
|
||||
if (!(e instanceof OfficeCliError)) throw e;
|
||||
// Only swallow if the resident is actually gone. If it's still alive (ping
|
||||
// momentarily unreachable), the close did NOT take effect — re-raise, or
|
||||
// the caller wrongly believes the file is released.
|
||||
if (await this.alive()) throw e;
|
||||
return ''; // resident gone / ack lost — end state is "closed"
|
||||
}
|
||||
}
|
||||
|
||||
async [Symbol.asyncDispose]() {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a blank Office document and return a live Document handle. Extra CLI
|
||||
* flags pass through verbatim:
|
||||
* await create('report.xlsx', ['--force']);
|
||||
* await create('doc', ['--type', 'docx']);
|
||||
* One CLI spawn (`officecli create`), which auto-starts a resident; the handle
|
||||
* binds to THAT resident (no second spawn). Inherits officecli's semantics —
|
||||
* file_locked (close it first) / file_exists (pass '--force').
|
||||
*/
|
||||
async function create(filePath, args = [], { binary = 'officecli', timeoutMs = 30000, autoInstall = true } = {}) {
|
||||
const full = path.resolve(filePath);
|
||||
const bin = await ensureCliBinary(binary, autoInstall);
|
||||
const r = runCli(bin, ['create', full, ...args]);
|
||||
if (r.status !== 0) throw new OfficeCliError(r.status == null ? -1 : r.status, r.stderr || r.stdout);
|
||||
const doc = new Document(full, bin, timeoutMs);
|
||||
await doc._start(); // create auto-started a resident; this finds it alive (no extra spawn)
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an EXISTING document and return a live Document handle. `officecli open`
|
||||
* is idempotent: reuse a resident already serving this file or start one.
|
||||
*
|
||||
* Owner — `const d = await open(f); try { ... } finally { await d.close(); }`
|
||||
* Borrow — `const d = await open(f); await d.send(...)` // leave it running
|
||||
*
|
||||
* Failure model (per send/batch): a DEAD resident is transparently restarted and
|
||||
* the command retried once; an ALIVE-but-busy pipe raises OfficeCliError (retry,
|
||||
* or close() and reopen).
|
||||
*/
|
||||
async function open(filePath, { binary = 'officecli', timeoutMs = 30000, autoInstall = true } = {}) {
|
||||
const bin = await ensureCliBinary(binary, autoInstall);
|
||||
const doc = new Document(filePath, bin, timeoutMs);
|
||||
await doc._start();
|
||||
// Mirror CLI `open`: when reusing a resident create() auto-started with a
|
||||
// short 60s timeout, upgrade it to the 12min interactive window. (If _start
|
||||
// spawned `officecli open` instead, that path already set 12min; re-sending
|
||||
// is idempotent.)
|
||||
await doc._setIdleTimeout(OPEN_IDLE_SECONDS);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the officecli CLI binary via its OFFICIAL installer — explicit by
|
||||
* design (this SDK never auto-downloads behind your back). Runs install.ps1 via
|
||||
* PowerShell on Windows and install.sh via bash elsewhere. Note: when installed
|
||||
* via npm, @officecli/officecli already bundles an auto-updating binary, so this
|
||||
* is only needed for a standalone (~/.local/bin or %LOCALAPPDATA%\OfficeCLI)
|
||||
* install.
|
||||
*/
|
||||
function install() {
|
||||
if (IS_WIN) {
|
||||
process.stderr.write(`Installing officecli via ${INSTALL_PS1_MIRROR} (github fallback) ...\n`);
|
||||
// Fetch the script mirror-first, github fallback, then run it. The whole
|
||||
// try/catch is assigned so a mirror failure transparently falls back.
|
||||
const ps = `$s = try { irm '${INSTALL_PS1_MIRROR}' } catch { irm '${INSTALL_PS1_GITHUB}' }; $s | iex`;
|
||||
const r = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (r.status !== 0) {
|
||||
throw new OfficeCliError(
|
||||
r.status == null ? -1 : r.status,
|
||||
`officecli install failed. Run manually:\n irm ${INSTALL_PS1_MIRROR} | iex`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`Installing officecli via ${INSTALL_SH_MIRROR} (github fallback) ...\n`);
|
||||
// (curl mirror || curl github) | bash — the subshell emits whichever script
|
||||
// fetch succeeds; the group keeps the pipe bound to the whole fallback.
|
||||
const sh = `(curl -fsSL ${INSTALL_SH_MIRROR} 2>/dev/null || curl -fsSL ${INSTALL_SH_GITHUB}) | bash`;
|
||||
const r = spawnSync('bash', ['-c', sh], { stdio: 'inherit' });
|
||||
if (r.status !== 0) {
|
||||
throw new OfficeCliError(
|
||||
r.status == null ? -1 : r.status,
|
||||
`officecli install failed. Run manually:\n curl -fsSL ${INSTALL_SH_MIRROR} | bash`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { open, create, install, Document, OfficeCliError, pipePaths };
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@officecli/sdk",
|
||||
"version": "0.1.2",
|
||||
"description": "Node.js SDK for officecli — a thin async client over the officecli resident pipe (read/write .docx/.xlsx/.pptx).",
|
||||
"license": "Apache-2.0",
|
||||
"author": "goworm",
|
||||
"homepage": "https://github.com/iOfficeAI/OfficeCLI/tree/main/sdk/node",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/iOfficeAI/OfficeCLI.git",
|
||||
"directory": "sdk/node"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/iOfficeAI/OfficeCLI/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"officecli",
|
||||
"office",
|
||||
"docx",
|
||||
"xlsx",
|
||||
"pptx",
|
||||
"word",
|
||||
"excel",
|
||||
"powerpoint",
|
||||
"ooxml",
|
||||
"sdk"
|
||||
],
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@officecli/officecli": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
// CI smoke test (not shipped — excluded from package.json "files"). On a runner
|
||||
// without officecli on PATH, create() triggers auto-install (install.sh on unix,
|
||||
// install.ps1 on Windows), proving the cross-platform provisioning + the pipe
|
||||
// round-trip end to end. Exits non-zero on any failure.
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const oc = require('./index.js');
|
||||
|
||||
(async () => {
|
||||
const f = path.join(os.tmpdir(), `officecli-smoke-${process.pid}.xlsx`);
|
||||
const d = await oc.create(f, ['--force']);
|
||||
await d.send({ command: 'set', path: '/Sheet1/A1', props: { text: 'smoke-ok' } });
|
||||
const g = await d.send({ command: 'get', path: '/Sheet1/A1' });
|
||||
await d.close();
|
||||
try { fs.unlinkSync(f); } catch (_) { /* ignore */ }
|
||||
if (!JSON.stringify(g).includes('smoke-ok')) {
|
||||
console.error('node SDK smoke FAIL: A1 mismatch', JSON.stringify(g));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('node SDK smoke PASS');
|
||||
})().catch((e) => {
|
||||
console.error('node SDK smoke THREW:', (e && e.message) || e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user