@procedures/restic 0.2.3 → 0.4.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.
package/src/procedures.ts CHANGED
@@ -4,10 +4,13 @@
4
4
  * restic reads the repository and its keys from the environment (`RESTIC_REPOSITORY`,
5
5
  * `RESTIC_PASSWORD`, `B2_ACCOUNT_ID`, `AWS_ACCESS_KEY_ID` and the others), so no secret is an
6
6
  * input. `--password-command` is refused: it starts a program of the caller.
7
+ *
8
+ * A host gives secrets only to the restic process with the `env` option of `runRestic()` or of
9
+ * `createResticProcedures()`. The procedure input has no field for them.
7
10
  */
8
11
 
9
12
  import { createProcedure, zodAdapter, type AnyProcedure, type ProcedureContext } from "@procedures/client";
10
- import { runCommand } from "@procedures/shell/command";
13
+ import { runCommand, type CommandOptions, type CommandResult } from "@procedures/shell/command";
11
14
  import { z } from "zod";
12
15
  import { buildArgv, parseJsonOutput, type ArgValue } from "./argv.js";
13
16
 
@@ -32,20 +35,25 @@ const RunInputSchema = z.object({
32
35
  });
33
36
  type RunInput = z.infer<typeof RunInputSchema>;
34
37
 
35
- interface RunOutput {
38
+ /** The result of a restic command. */
39
+ export interface ResticResult {
36
40
  exitCode: number;
37
41
  success: boolean;
38
42
  stdout: string;
39
43
  stderr: string;
40
44
  /** The JSON output, when `json` is true and restic gave JSON */
41
45
  json?: unknown;
46
+ /** Present when an output stream was longer than `maxOutputBytes`: which one lost bytes. */
47
+ truncated?: { stdout: boolean; stderr: boolean } | undefined;
42
48
  }
49
+ type RunOutput = ResticResult;
43
50
  const RunOutputSchema = z.object({
44
51
  exitCode: z.number(),
45
52
  success: z.boolean(),
46
53
  stdout: z.string(),
47
54
  stderr: z.string(),
48
55
  json: z.unknown().optional(),
56
+ truncated: z.object({ stdout: z.boolean(), stderr: z.boolean() }).optional(),
49
57
  });
50
58
 
51
59
  /** A restic command: the subcommand path, its arguments and the repository. */
@@ -66,37 +74,108 @@ export function resticArgv(input: ResticCommand): string[] {
66
74
  return buildArgv({ path: input.path, args: input.args, positional: input.positional, flags });
67
75
  }
68
76
 
69
- export const resticRunProcedure: AnyProcedure = createProcedure()
70
- .path(["restic", "run"])
71
- .input(zodAdapter<RunInput>(RunInputSchema))
72
- .output(zodAdapter<RunOutput>(RunOutputSchema))
73
- .meta({
74
- description: "Run a restic command (backup, snapshots, restore, forget, check)",
75
- args: ["path"],
76
- shorts: { cwd: "C", timeout: "t" },
77
- output: "json",
78
- // A backup reads files, a restore writes them, and forget and prune delete snapshots. A
79
- // repository of type sftp or rclone starts that program.
80
- effects: { reads: ["fs", "network", "process"], writes: ["fs", "network", "process"], destructive: true },
81
- })
82
- .handler(async (input: RunInput, ctx?: ProcedureContext): Promise<RunOutput> => {
83
- const executable = input.executable ?? process.env["RESTIC_EXE"] ?? "restic";
84
- const result = await runCommand(executable, {
85
- args: resticArgv(input),
86
- cwd: input.cwd,
87
- timeout: input.timeout,
88
- signal: ctx?.signal,
89
- });
90
- if (result.error) throw new Error(`restic did not start: ${result.error}`);
91
- const json = input.json ? parseJsonOutput(result.stdout) : undefined;
92
- return {
93
- exitCode: result.exitCode,
94
- success: result.exitCode === 0,
95
- stdout: result.stdout,
96
- stderr: result.stderr,
97
- ...(json !== undefined ? { json } : {}),
98
- };
99
- })
100
- .build() as AnyProcedure;
77
+ /** A function that starts a program: `runCommand` of `@procedures/shell/command`, or a fake in a test. */
78
+ export type ResticRunner = (command: string, options: CommandOptions) => Promise<CommandResult>;
79
+
80
+ /** The options of `runRestic()` that do not come from the command. */
81
+ export interface ResticRunOptions {
82
+ /** The restic program (default: `RESTIC_EXE` of the environment of the host, then `restic`) */
83
+ readonly executable?: string | undefined;
84
+ /**
85
+ * The variables for the restic process only, for example `RESTIC_PASSWORD`,
86
+ * `RESTIC_REPOSITORY` or `B2_ACCOUNT_ID`. They go on top of the environment of the host. The
87
+ * value `undefined` removes a variable of the host from the environment of restic.
88
+ */
89
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
90
+ /** The working folder */
91
+ readonly cwd?: string | undefined;
92
+ /** The time limit, in milliseconds. At the limit, the process tree stops. */
93
+ readonly timeout?: number | undefined;
94
+ /** When the signal aborts, the process tree stops. */
95
+ readonly signal?: AbortSignal | undefined;
96
+ /**
97
+ * The most bytes kept of each output stream (default: 64 MiB, the default of `runCommand`).
98
+ * `restic ls` of a large snapshot can give more. Past the limit, the result has `truncated`.
99
+ */
100
+ readonly maxOutputBytes?: number | undefined;
101
+ /** The function that starts restic (default: `runCommand`). A test gives a fake. */
102
+ readonly run?: ResticRunner | undefined;
103
+ }
104
+
105
+ /**
106
+ * This function starts a restic command with argv, not through a shell, and reads its JSON
107
+ * output. `json` is true when the command does not set it, as in `restic.run`. The `env` values
108
+ * go only to the restic process: the host environment does not change, and the result does not
109
+ * contain them. The function throws when restic does not start. A failed command gives
110
+ * `success: false`.
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const executable = await findRestic();
115
+ * const result = await runRestic(
116
+ * { path: ["forget"], args: { "keep-daily": 14, prune: true } },
117
+ * { executable, env: { RESTIC_REPOSITORY: repository, RESTIC_PASSWORD: password } },
118
+ * );
119
+ * ```
120
+ */
121
+ export async function runRestic(command: ResticCommand, options: ResticRunOptions = {}): Promise<ResticResult> {
122
+ const executable = options.executable ?? process.env["RESTIC_EXE"] ?? "restic";
123
+ const json = command.json ?? true;
124
+ const run = options.run ?? runCommand;
125
+ const result = await run(executable, {
126
+ args: resticArgv({ ...command, json }),
127
+ cwd: options.cwd,
128
+ timeout: options.timeout,
129
+ signal: options.signal,
130
+ env: options.env === undefined ? undefined : { ...options.env },
131
+ ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
132
+ });
133
+ if (result.error) throw new Error(`restic did not start: ${result.error}`);
134
+ const parsed = json ? parseJsonOutput(result.stdout) : undefined;
135
+ return {
136
+ exitCode: result.exitCode,
137
+ success: result.exitCode === 0,
138
+ stdout: result.stdout,
139
+ stderr: result.stderr,
140
+ ...(parsed !== undefined ? { json: parsed } : {}),
141
+ ...(result.truncated !== undefined ? { truncated: result.truncated } : {}),
142
+ };
143
+ }
144
+
145
+ /** The settings of the host for the procedures of this package. The procedure input cannot change them. */
146
+ export interface ResticProceduresOptions {
147
+ /** The variables for the restic process only, for example `RESTIC_PASSWORD`. Refer to `ResticRunOptions.env`. */
148
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
149
+ /** The function that starts restic (default: `runCommand`). A test gives a fake. */
150
+ readonly run?: ResticRunner | undefined;
151
+ }
152
+
153
+ /**
154
+ * This function gives the procedures of this package with the settings of the host. With `env`,
155
+ * each restic process gets those variables, and the caller of `restic.run` does not see them.
156
+ */
157
+ export function createResticProcedures(options: ResticProceduresOptions = {}): AnyProcedure[] {
158
+ const run = createProcedure()
159
+ .path(["restic", "run"])
160
+ .input(zodAdapter<RunInput>(RunInputSchema))
161
+ .output(zodAdapter<RunOutput>(RunOutputSchema))
162
+ .meta({
163
+ description: "Run a restic command (backup, snapshots, restore, forget, check)",
164
+ args: ["path"],
165
+ shorts: { cwd: "C", timeout: "t" },
166
+ output: "json",
167
+ // A backup reads files, a restore writes them, and forget and prune delete snapshots. A
168
+ // repository of type sftp or rclone starts that program.
169
+ effects: { reads: ["fs", "network", "process"], writes: ["fs", "network", "process"], destructive: true },
170
+ })
171
+ .handler(
172
+ async (input: RunInput, ctx?: ProcedureContext): Promise<RunOutput> =>
173
+ runRestic(input, { executable: input.executable, env: options.env, cwd: input.cwd, timeout: input.timeout, signal: ctx?.signal, run: options.run }),
174
+ )
175
+ .build() as AnyProcedure;
176
+ return [run];
177
+ }
178
+
179
+ export const resticProcedures: AnyProcedure[] = createResticProcedures();
101
180
 
102
- export const resticProcedures: AnyProcedure[] = [resticRunProcedure];
181
+ export const resticRunProcedure: AnyProcedure = resticProcedures[0]!;