@ystemsrx/cfshare 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { CfshareManager, type CfshareRuntimeApi } from "./manager.js";
8
+ import type { CfsharePluginConfig } from "./types.js";
9
+
10
+ type CliOptions = {
11
+ command?: string;
12
+ paramsJson?: string;
13
+ paramsFile?: string;
14
+ configJson?: string;
15
+ configFile?: string;
16
+ workspaceDir?: string;
17
+ keepAlive?: boolean;
18
+ compact?: boolean;
19
+ help?: boolean;
20
+ version?: boolean;
21
+ };
22
+
23
+ const TOOL_NAMES = new Set([
24
+ "env_check",
25
+ "expose_port",
26
+ "expose_files",
27
+ "exposure_list",
28
+ "exposure_get",
29
+ "exposure_stop",
30
+ "exposure_logs",
31
+ "maintenance",
32
+ "audit_query",
33
+ "audit_export",
34
+ ]);
35
+
36
+ function normalizeCommand(input: string): string {
37
+ return input.trim().toLowerCase().replace(/-/g, "_");
38
+ }
39
+
40
+ function expandHome(input: string): string {
41
+ if (input === "~") {
42
+ return os.homedir();
43
+ }
44
+ if (input.startsWith("~/")) {
45
+ return path.join(os.homedir(), input.slice(2));
46
+ }
47
+ return input;
48
+ }
49
+
50
+ function resolvePathFromCwd(input: string): string {
51
+ const expanded = expandHome(input);
52
+ return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(process.cwd(), expanded);
53
+ }
54
+
55
+ function printHelp() {
56
+ const lines = [
57
+ "CFShare CLI",
58
+ "",
59
+ "Usage:",
60
+ " cfshare <tool> [params-json] [options]",
61
+ "",
62
+ "Tools:",
63
+ " env_check",
64
+ " expose_port",
65
+ " expose_files",
66
+ " exposure_list",
67
+ " exposure_get",
68
+ " exposure_stop",
69
+ " exposure_logs",
70
+ " maintenance",
71
+ " audit_query",
72
+ " audit_export",
73
+ "",
74
+ "Options:",
75
+ " --params <json> Tool parameters as JSON",
76
+ " --params-file <path> Read tool parameters from JSON file",
77
+ " --config <json> Runtime config JSON (same as plugin config)",
78
+ " --config-file <path> Read runtime config from JSON file",
79
+ " --workspace-dir <dir> Workspace dir for expose_files context",
80
+ " --keep-alive Keep process running after expose_*",
81
+ " --no-keep-alive Exit immediately after expose_* result",
82
+ " --compact Compact JSON output",
83
+ " -h, --help Show help",
84
+ " -v, --version Show version",
85
+ "",
86
+ "Examples:",
87
+ " cfshare env_check",
88
+ " cfshare expose_port '{\"port\":3000,\"opts\":{\"access\":\"token\"}}'",
89
+ " cfshare expose_files --params '{\"paths\":[\"./build\"],\"opts\":{\"access\":\"none\"}}'",
90
+ " cfshare exposure_stop --params '{\"id\":\"all\"}'",
91
+ ];
92
+ process.stdout.write(`${lines.join("\n")}\n`);
93
+ }
94
+
95
+ function assertValue(args: string[], index: number, flag: string): string {
96
+ const value = args[index];
97
+ if (!value || value.startsWith("-")) {
98
+ throw new Error(`missing value for ${flag}`);
99
+ }
100
+ return value;
101
+ }
102
+
103
+ function parseArgs(argv: string[]): CliOptions {
104
+ const opts: CliOptions = {};
105
+ const positionals: string[] = [];
106
+
107
+ for (let i = 0; i < argv.length; i += 1) {
108
+ const token = argv[i];
109
+ if (!token) {
110
+ continue;
111
+ }
112
+ if (token === "-h" || token === "--help") {
113
+ opts.help = true;
114
+ continue;
115
+ }
116
+ if (token === "-v" || token === "--version") {
117
+ opts.version = true;
118
+ continue;
119
+ }
120
+ if (token === "--params") {
121
+ opts.paramsJson = assertValue(argv, i + 1, token);
122
+ i += 1;
123
+ continue;
124
+ }
125
+ if (token === "--params-file") {
126
+ opts.paramsFile = assertValue(argv, i + 1, token);
127
+ i += 1;
128
+ continue;
129
+ }
130
+ if (token === "--config") {
131
+ opts.configJson = assertValue(argv, i + 1, token);
132
+ i += 1;
133
+ continue;
134
+ }
135
+ if (token === "--config-file") {
136
+ opts.configFile = assertValue(argv, i + 1, token);
137
+ i += 1;
138
+ continue;
139
+ }
140
+ if (token === "--workspace-dir") {
141
+ opts.workspaceDir = assertValue(argv, i + 1, token);
142
+ i += 1;
143
+ continue;
144
+ }
145
+ if (token === "--keep-alive") {
146
+ opts.keepAlive = true;
147
+ continue;
148
+ }
149
+ if (token === "--no-keep-alive") {
150
+ opts.keepAlive = false;
151
+ continue;
152
+ }
153
+ if (token === "--compact") {
154
+ opts.compact = true;
155
+ continue;
156
+ }
157
+ if (token.startsWith("-")) {
158
+ throw new Error(`unknown option: ${token}`);
159
+ }
160
+ positionals.push(token);
161
+ }
162
+
163
+ if (positionals.length > 0) {
164
+ opts.command = positionals[0];
165
+ }
166
+ if (positionals.length > 1) {
167
+ if (opts.paramsJson || opts.paramsFile) {
168
+ throw new Error("params-json conflicts with --params/--params-file");
169
+ }
170
+ opts.paramsJson = positionals[1];
171
+ }
172
+ if (positionals.length > 2) {
173
+ throw new Error("too many positional arguments");
174
+ }
175
+
176
+ return opts;
177
+ }
178
+
179
+ async function parseJsonInput(source: string, label: string): Promise<unknown> {
180
+ try {
181
+ return JSON.parse(source);
182
+ } catch (error) {
183
+ throw new Error(`failed to parse ${label}: ${String(error)}`);
184
+ }
185
+ }
186
+
187
+ async function parseJsonFile(filePath: string, label: string): Promise<unknown> {
188
+ const resolved = resolvePathFromCwd(filePath);
189
+ const content = await fs.readFile(resolved, "utf8");
190
+ return await parseJsonInput(content, `${label} (${resolved})`);
191
+ }
192
+
193
+ function asObject(input: unknown, label: string): Record<string, unknown> {
194
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
195
+ throw new Error(`${label} must be a JSON object`);
196
+ }
197
+ return input as Record<string, unknown>;
198
+ }
199
+
200
+ function createRuntimeApi(config: CfsharePluginConfig): CfshareRuntimeApi {
201
+ const stringifyArgs = (args: unknown[]) =>
202
+ args
203
+ .map((value) => {
204
+ if (typeof value === "string") {
205
+ return value;
206
+ }
207
+ try {
208
+ return JSON.stringify(value);
209
+ } catch {
210
+ return String(value);
211
+ }
212
+ })
213
+ .join(" ");
214
+
215
+ const logger = {
216
+ info: (...args: unknown[]) => {
217
+ if (process.env.CFSHARE_LOG_LEVEL === "info" || process.env.CFSHARE_LOG_LEVEL === "debug") {
218
+ process.stderr.write(`[cfshare] ${stringifyArgs(args)}\n`);
219
+ }
220
+ },
221
+ warn: (...args: unknown[]) => {
222
+ process.stderr.write(`[cfshare][warn] ${stringifyArgs(args)}\n`);
223
+ },
224
+ error: (...args: unknown[]) => {
225
+ process.stderr.write(`[cfshare][error] ${stringifyArgs(args)}\n`);
226
+ },
227
+ debug: (...args: unknown[]) => {
228
+ if (process.env.CFSHARE_LOG_LEVEL === "debug") {
229
+ process.stderr.write(`[cfshare][debug] ${stringifyArgs(args)}\n`);
230
+ }
231
+ },
232
+ } as unknown as CfshareRuntimeApi["logger"];
233
+
234
+ return {
235
+ logger,
236
+ resolvePath: resolvePathFromCwd,
237
+ pluginConfig: config,
238
+ };
239
+ }
240
+
241
+ function shouldKeepAlive(command: string, keepAliveFlag: boolean | undefined): boolean {
242
+ if (typeof keepAliveFlag === "boolean") {
243
+ return keepAliveFlag;
244
+ }
245
+ return command === "expose_port" || command === "expose_files";
246
+ }
247
+
248
+ async function waitUntilExposureStops(manager: CfshareManager, id: string): Promise<void> {
249
+ await new Promise<void>((resolve, reject) => {
250
+ let stopping = false;
251
+ let interval: NodeJS.Timeout | undefined;
252
+
253
+ const shutdown = async (reason: string) => {
254
+ if (stopping) {
255
+ return;
256
+ }
257
+ stopping = true;
258
+ if (interval) {
259
+ clearInterval(interval);
260
+ }
261
+ process.removeListener("SIGINT", onSigint);
262
+ process.removeListener("SIGTERM", onSigterm);
263
+ try {
264
+ await manager.stopExposure(id, { reason });
265
+ } catch {
266
+ // best effort cleanup on signal
267
+ } finally {
268
+ resolve();
269
+ }
270
+ };
271
+
272
+ const onSigint = () => {
273
+ void shutdown("cli interrupted");
274
+ };
275
+ const onSigterm = () => {
276
+ void shutdown("cli terminated");
277
+ };
278
+ process.once("SIGINT", onSigint);
279
+ process.once("SIGTERM", onSigterm);
280
+
281
+ interval = setInterval(async () => {
282
+ try {
283
+ const detail = (await manager.exposureGet({ id })) as { status?: unknown };
284
+ const statusValue = detail.status;
285
+ const state =
286
+ typeof statusValue === "string"
287
+ ? statusValue
288
+ : typeof statusValue === "object" && statusValue && "state" in statusValue
289
+ ? String((statusValue as { state?: unknown }).state ?? "")
290
+ : "";
291
+ if (state === "stopped" || state === "expired" || state === "error" || state === "not_found") {
292
+ clearInterval(interval);
293
+ process.removeListener("SIGINT", onSigint);
294
+ process.removeListener("SIGTERM", onSigterm);
295
+ resolve();
296
+ }
297
+ } catch (error) {
298
+ clearInterval(interval);
299
+ process.removeListener("SIGINT", onSigint);
300
+ process.removeListener("SIGTERM", onSigterm);
301
+ reject(error);
302
+ }
303
+ }, 1000);
304
+ });
305
+ }
306
+
307
+ async function runTool(
308
+ manager: CfshareManager,
309
+ command: string,
310
+ params: Record<string, unknown>,
311
+ opts: CliOptions,
312
+ ): Promise<unknown> {
313
+ if (command === "env_check") {
314
+ return await manager.envCheck();
315
+ }
316
+ if (command === "expose_port") {
317
+ return await manager.exposePort(params as { port: number; opts?: Record<string, unknown> });
318
+ }
319
+ if (command === "expose_files") {
320
+ const ctx = opts.workspaceDir ? { workspaceDir: opts.workspaceDir } : undefined;
321
+ return await manager.exposeFiles(
322
+ params as {
323
+ paths: string[];
324
+ opts?: {
325
+ mode?: "normal" | "zip";
326
+ presentation?: "download" | "preview" | "raw";
327
+ ttl_seconds?: number;
328
+ access?: "token" | "basic" | "none";
329
+ max_downloads?: number;
330
+ };
331
+ },
332
+ ctx,
333
+ );
334
+ }
335
+ if (command === "exposure_list") {
336
+ return manager.exposureList();
337
+ }
338
+ if (command === "exposure_get") {
339
+ return await manager.exposureGet(
340
+ params as {
341
+ id?: string;
342
+ ids?: string[];
343
+ filter?: {
344
+ status?: "starting" | "running" | "stopped" | "error" | "expired";
345
+ type?: "port" | "files";
346
+ };
347
+ fields?: Array<
348
+ | "id"
349
+ | "type"
350
+ | "status"
351
+ | "port"
352
+ | "public_url"
353
+ | "expires_at"
354
+ | "local_url"
355
+ | "stats"
356
+ | "file_sharing"
357
+ | "last_error"
358
+ | "manifest"
359
+ | "created_at"
360
+ >;
361
+ opts?: {
362
+ probe_public?: boolean;
363
+ };
364
+ },
365
+ );
366
+ }
367
+ if (command === "exposure_stop") {
368
+ const stopParams = params as { id?: string; ids?: string[]; opts?: { reason?: string } };
369
+ const target = stopParams.ids ?? stopParams.id;
370
+ if (!target) {
371
+ throw new Error("exposure_stop requires id or ids");
372
+ }
373
+ return await manager.stopExposure(target, stopParams.opts);
374
+ }
375
+ if (command === "exposure_logs") {
376
+ const logParams = params as {
377
+ id?: string;
378
+ ids?: string[];
379
+ opts?: { lines?: number; since_seconds?: number; component?: "tunnel" | "origin" | "all" };
380
+ };
381
+ const target = logParams.ids ?? logParams.id;
382
+ if (!target) {
383
+ throw new Error("exposure_logs requires id or ids");
384
+ }
385
+ return manager.exposureLogs(target, logParams.opts);
386
+ }
387
+ if (command === "maintenance") {
388
+ const maintenanceParams = params as {
389
+ action: "start_guard" | "run_gc" | "set_policy";
390
+ opts?: { policy?: unknown; ignore_patterns?: string[] };
391
+ };
392
+ return await manager.maintenance(maintenanceParams.action, maintenanceParams.opts);
393
+ }
394
+ if (command === "audit_query") {
395
+ const queryParams = params as {
396
+ filters?: {
397
+ id?: string;
398
+ event?: string;
399
+ type?: "port" | "files";
400
+ from_ts?: string;
401
+ to_ts?: string;
402
+ limit?: number;
403
+ };
404
+ };
405
+ return await manager.auditQuery(queryParams.filters);
406
+ }
407
+ if (command === "audit_export") {
408
+ const exportParams = params as {
409
+ range?: {
410
+ from_ts?: string;
411
+ to_ts?: string;
412
+ id?: string;
413
+ event?: string;
414
+ type?: "port" | "files";
415
+ output_path?: string;
416
+ };
417
+ };
418
+ return await manager.auditExport(exportParams.range);
419
+ }
420
+ throw new Error(`unsupported command: ${command}`);
421
+ }
422
+
423
+ async function readVersion(): Promise<string> {
424
+ const packagePath = new URL("../../package.json", import.meta.url);
425
+ const content = await fs.readFile(packagePath, "utf8");
426
+ const parsed = JSON.parse(content) as { version?: string };
427
+ return parsed.version ?? "unknown";
428
+ }
429
+
430
+ async function main() {
431
+ const options = parseArgs(process.argv.slice(2));
432
+
433
+ if (options.version) {
434
+ process.stdout.write(`${await readVersion()}\n`);
435
+ return;
436
+ }
437
+
438
+ if (options.help || !options.command) {
439
+ printHelp();
440
+ process.exit(options.help ? 0 : 1);
441
+ }
442
+
443
+ const command = normalizeCommand(options.command);
444
+ if (!TOOL_NAMES.has(command)) {
445
+ throw new Error(`unknown tool: ${options.command}`);
446
+ }
447
+
448
+ const paramsInput =
449
+ options.paramsJson !== undefined
450
+ ? await parseJsonInput(options.paramsJson, "--params")
451
+ : options.paramsFile
452
+ ? await parseJsonFile(options.paramsFile, "--params-file")
453
+ : {};
454
+ const configInput =
455
+ options.configJson !== undefined
456
+ ? await parseJsonInput(options.configJson, "--config")
457
+ : options.configFile
458
+ ? await parseJsonFile(options.configFile, "--config-file")
459
+ : {};
460
+
461
+ const params = asObject(paramsInput, "params");
462
+ const config = asObject(configInput, "config") as CfsharePluginConfig;
463
+ const manager = new CfshareManager(createRuntimeApi(config));
464
+
465
+ const result = await runTool(manager, command, params, options);
466
+ process.stdout.write(`${JSON.stringify(result, null, options.compact ? undefined : 2)}\n`);
467
+
468
+ if (shouldKeepAlive(command, options.keepAlive)) {
469
+ const exposureId = typeof result === "object" && result ? (result as { id?: unknown }).id : undefined;
470
+ if (typeof exposureId !== "string" || !exposureId) {
471
+ return;
472
+ }
473
+ process.stderr.write(
474
+ `cfshare: exposure ${exposureId} is running. Press Ctrl+C to stop or use --no-keep-alive.\n`,
475
+ );
476
+ await waitUntilExposureStops(manager, exposureId);
477
+ }
478
+ }
479
+
480
+ void main().catch((error) => {
481
+ const message = error instanceof Error ? error.message : String(error);
482
+ process.stderr.write(`cfshare error: ${message}\n`);
483
+ process.exit(1);
484
+ });
package/src/manager.ts CHANGED
@@ -30,6 +30,8 @@ import type {
30
30
  RateLimitPolicy,
31
31
  } from "./types.js";
32
32
 
33
+ export type CfshareRuntimeApi = Pick<OpenClawPluginApi, "logger" | "resolvePath" | "pluginConfig">;
34
+
33
35
  const MAX_LOG_LINES = 4000;
34
36
  const MAX_RESPONSE_MANIFEST_ITEMS = 200;
35
37
  const MAX_RESPONSE_MANIFEST_ITEMS_MULTI_GET = 20;
@@ -715,7 +717,7 @@ function matchAuditFilters(
715
717
  }
716
718
 
717
719
  export class CfshareManager {
718
- private readonly logger: OpenClawPluginApi["logger"];
720
+ private readonly logger: CfshareRuntimeApi["logger"];
719
721
  private readonly resolvePath: (input: string) => string;
720
722
  private readonly pluginConfig: CfsharePluginConfig;
721
723
  private readonly cloudflaredPathInput: string;
@@ -736,7 +738,7 @@ export class CfshareManager {
736
738
  private guardTimer?: NodeJS.Timeout;
737
739
  private readonly sessions = new Map<string, ExposureSession>();
738
740
 
739
- constructor(api: OpenClawPluginApi) {
741
+ constructor(api: CfshareRuntimeApi) {
740
742
  this.logger = api.logger;
741
743
  this.resolvePath = api.resolvePath;
742
744
  this.pluginConfig = (api.pluginConfig ?? {}) as CfsharePluginConfig;
@@ -893,7 +895,7 @@ export class CfshareManager {
893
895
  return queryToken === access.token || headerToken === access.token || bearer === access.token;
894
896
  }
895
897
  const basic = parseBasicAuth(req.headers.authorization);
896
- return basic?.username === access.username && basic.password === access.password;
898
+ return basic?.username === access.username && basic?.password === access.password;
897
899
  }
898
900
 
899
901
  private async startReverseProxy(params: {
@@ -1609,7 +1611,7 @@ export class CfshareManager {
1609
1611
  this.appendLog(session, "tunnel", `spawn: ${cloudflaredBin} ${args.join(" ")}`);
1610
1612
 
1611
1613
  const proc = spawn(cloudflaredBin, args, {
1612
- stdio: ["ignore", "pipe", "pipe"],
1614
+ stdio: ["pipe", "pipe", "pipe"],
1613
1615
  });
1614
1616
 
1615
1617
  let settled = false;
@@ -1725,6 +1727,9 @@ export class CfshareManager {
1725
1727
  return;
1726
1728
  }
1727
1729
  const pid = proc.pid;
1730
+ if (!pid) {
1731
+ return;
1732
+ }
1728
1733
  try {
1729
1734
  process.kill(pid, 0);
1730
1735
  } catch {
package/src/shims.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ declare module "openclaw/plugin-sdk" {
2
+ import type { TSchema } from "@sinclair/typebox";
3
+
4
+ export type OpenClawToolContext = {
5
+ workspaceDir?: string;
6
+ };
7
+
8
+ export type OpenClawToolDefinition = {
9
+ name: string;
10
+ label: string;
11
+ description: string;
12
+ parameters?: TSchema | Record<string, unknown>;
13
+ execute: (...args: any[]) => unknown | Promise<unknown>;
14
+ };
15
+
16
+ export type OpenClawPluginApi = {
17
+ logger: {
18
+ info: (...args: unknown[]) => void;
19
+ warn: (...args: unknown[]) => void;
20
+ error: (...args: unknown[]) => void;
21
+ debug: (...args: unknown[]) => void;
22
+ };
23
+ resolvePath: (input: string) => string;
24
+ pluginConfig?: Record<string, unknown>;
25
+ registerTool: (
26
+ factory:
27
+ | ((ctx: OpenClawToolContext) => OpenClawToolDefinition[])
28
+ | ((ctx: OpenClawToolContext) => Promise<OpenClawToolDefinition[]>),
29
+ options?: {
30
+ names?: string[];
31
+ },
32
+ ) => void;
33
+ };
34
+
35
+ export function emptyPluginConfigSchema(): Record<string, unknown>;
36
+ export function jsonResult<T>(value: T): T;
37
+ export function stringEnum<const T extends readonly string[]>(
38
+ values: T,
39
+ options?: Record<string, unknown>,
40
+ ): TSchema;
41
+ }
42
+
43
+ declare module "yazl" {
44
+ class ZipFile {
45
+ outputStream: NodeJS.ReadableStream;
46
+ addFile(realPath: string, metadataPath: string, options?: Record<string, unknown>): void;
47
+ end(options?: Record<string, unknown>, callback?: () => void): void;
48
+ }
49
+
50
+ const yazl: {
51
+ ZipFile: typeof ZipFile;
52
+ };
53
+
54
+ export default yazl;
55
+ }
package/src/tools.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { TSchema } from "@sinclair/typebox";
1
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
3
  import { jsonResult } from "openclaw/plugin-sdk";
3
4
  import { CfshareManager } from "./manager.js";
@@ -27,7 +28,15 @@ type ToolContext = {
27
28
  workspaceDir?: string;
28
29
  };
29
30
 
30
- function registerToolsForContext(api: OpenClawPluginApi, ctx: ToolContext) {
31
+ type RegisteredTool = {
32
+ name: string;
33
+ label: string;
34
+ description: string;
35
+ parameters: TSchema | Record<string, unknown>;
36
+ execute: (...args: any[]) => Promise<unknown>;
37
+ };
38
+
39
+ function registerToolsForContext(api: OpenClawPluginApi, ctx: ToolContext): RegisteredTool[] {
31
40
  const manager = getManager(api);
32
41
 
33
42
  return [
@@ -250,7 +259,7 @@ export function registerCfshareTools(api: OpenClawPluginApi) {
250
259
  "audit_export",
251
260
  ];
252
261
 
253
- api.registerTool((ctx) => registerToolsForContext(api, ctx), {
262
+ api.registerTool((ctx: ToolContext) => registerToolsForContext(api, ctx), {
254
263
  names,
255
264
  });
256
265
  }