@procedures/snapshot 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +737 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/index.d.ts +16 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +18 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/module.d.ts +15 -0
  10. package/dist/module.d.ts.map +1 -0
  11. package/dist/module.js +99 -0
  12. package/dist/module.js.map +1 -0
  13. package/dist/procedures/snapshot/archive-io.d.ts +12 -0
  14. package/dist/procedures/snapshot/archive-io.d.ts.map +1 -0
  15. package/dist/procedures/snapshot/archive-io.js +29 -0
  16. package/dist/procedures/snapshot/archive-io.js.map +1 -0
  17. package/dist/procedures/snapshot/commands.d.ts +15 -0
  18. package/dist/procedures/snapshot/commands.d.ts.map +1 -0
  19. package/dist/procedures/snapshot/commands.js +32 -0
  20. package/dist/procedures/snapshot/commands.js.map +1 -0
  21. package/dist/procedures/snapshot/create.d.ts +12 -0
  22. package/dist/procedures/snapshot/create.d.ts.map +1 -0
  23. package/dist/procedures/snapshot/create.js +279 -0
  24. package/dist/procedures/snapshot/create.js.map +1 -0
  25. package/dist/procedures/snapshot/delete.d.ts +12 -0
  26. package/dist/procedures/snapshot/delete.d.ts.map +1 -0
  27. package/dist/procedures/snapshot/delete.js +31 -0
  28. package/dist/procedures/snapshot/delete.js.map +1 -0
  29. package/dist/procedures/snapshot/diff.d.ts +12 -0
  30. package/dist/procedures/snapshot/diff.d.ts.map +1 -0
  31. package/dist/procedures/snapshot/diff.js +113 -0
  32. package/dist/procedures/snapshot/diff.js.map +1 -0
  33. package/dist/procedures/snapshot/index.d.ts +9 -0
  34. package/dist/procedures/snapshot/index.d.ts.map +1 -0
  35. package/dist/procedures/snapshot/index.js +9 -0
  36. package/dist/procedures/snapshot/index.js.map +1 -0
  37. package/dist/procedures/snapshot/list.d.ts +12 -0
  38. package/dist/procedures/snapshot/list.d.ts.map +1 -0
  39. package/dist/procedures/snapshot/list.js +57 -0
  40. package/dist/procedures/snapshot/list.js.map +1 -0
  41. package/dist/procedures/snapshot/names.d.ts +10 -0
  42. package/dist/procedures/snapshot/names.d.ts.map +1 -0
  43. package/dist/procedures/snapshot/names.js +20 -0
  44. package/dist/procedures/snapshot/names.js.map +1 -0
  45. package/dist/procedures/snapshot/restore.d.ts +12 -0
  46. package/dist/procedures/snapshot/restore.d.ts.map +1 -0
  47. package/dist/procedures/snapshot/restore.js +131 -0
  48. package/dist/procedures/snapshot/restore.js.map +1 -0
  49. package/dist/procedures/snapshot/s3-lookup.d.ts +39 -0
  50. package/dist/procedures/snapshot/s3-lookup.d.ts.map +1 -0
  51. package/dist/procedures/snapshot/s3-lookup.js +57 -0
  52. package/dist/procedures/snapshot/s3-lookup.js.map +1 -0
  53. package/dist/register.d.ts +8 -0
  54. package/dist/register.d.ts.map +1 -0
  55. package/dist/register.js +14 -0
  56. package/dist/register.js.map +1 -0
  57. package/dist/types.d.ts +193 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +168 -0
  60. package/dist/types.js.map +1 -0
  61. package/package.json +84 -3
  62. package/src/index.ts +52 -0
  63. package/src/module.ts +136 -0
  64. package/src/procedures/snapshot/archive-io.ts +31 -0
  65. package/src/procedures/snapshot/commands.ts +34 -0
  66. package/src/procedures/snapshot/create.ts +354 -0
  67. package/src/procedures/snapshot/delete.ts +40 -0
  68. package/src/procedures/snapshot/diff.ts +147 -0
  69. package/src/procedures/snapshot/index.ts +9 -0
  70. package/src/procedures/snapshot/list.ts +80 -0
  71. package/src/procedures/snapshot/names.ts +24 -0
  72. package/src/procedures/snapshot/restore.ts +166 -0
  73. package/src/procedures/snapshot/s3-lookup.ts +88 -0
  74. package/src/register.ts +17 -0
  75. package/src/types.ts +381 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * snapshot.restore procedure
3
+ *
4
+ * Restore an environment snapshot from S3.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import * as tar from "tar";
11
+ import type { ProcedureContext } from "@procedures/client";
12
+ import type {
13
+ SnapshotRestoreInput,
14
+ SnapshotRestoreOutput,
15
+ SnapshotMetadata,
16
+ } from "../../types.js";
17
+ import { listAllObjects, findSnapshotKey } from "./s3-lookup.js";
18
+ import { assertSnapshotName } from "./names.js";
19
+ import { sha256File } from "./archive-io.js";
20
+
21
+ /**
22
+ * Restore a snapshot from S3
23
+ */
24
+ export async function snapshotRestore(
25
+ input: SnapshotRestoreInput,
26
+ ctx: ProcedureContext
27
+ ): Promise<SnapshotRestoreOutput> {
28
+ // Scratch directory used solely to hold the downloaded archive; it is always
29
+ // safe to delete. The extraction target is `input.targetPath` (required) and
30
+ // is never removed — that is the fix for the self-deleting-restore bug where
31
+ // targetPath defaulted to workDir and the `finally` then wiped it.
32
+ //
33
+ // The id must be a plain name, and the scratch folder is a new unique folder: before, the id
34
+ // went into the path, so `x/../../<dir>` made the `finally` delete <dir>, even when the S3
35
+ // lookup failed (deep dive DATA-1).
36
+ assertSnapshotName("id", input.id);
37
+ const workDir = mkdtempSync(join(tmpdir(), "restore-"));
38
+
39
+ try {
40
+
41
+ // First, download metadata to get archive location
42
+ const downloadStart = Date.now();
43
+
44
+ // Find the snapshot by ID (paginated listing, exact basename match).
45
+ const objects = await listAllObjects(ctx, input.bucket, "snapshots/");
46
+ const metadataKey = findSnapshotKey(objects, input.id, ".metadata.json");
47
+
48
+ if (!metadataKey) {
49
+ throw new Error(`Snapshot not found: ${input.id}`);
50
+ }
51
+
52
+ // Download metadata
53
+ const metadataResult = await ctx.client.call<
54
+ { bucket: string; key: string; encoding?: string },
55
+ { body: string }
56
+ >(["s3", "download"], {
57
+ bucket: input.bucket,
58
+ key: metadataKey,
59
+ encoding: "utf8",
60
+ });
61
+
62
+ const metadata: SnapshotMetadata = JSON.parse(metadataResult.body);
63
+
64
+ // Download archive
65
+ const archiveKey = metadataKey.replace(".metadata.json", ".tar.gz");
66
+ const archivePath = join(workDir, `${input.id}.tar.gz`);
67
+
68
+ // Stream the archive to disk: an archive can be large, and loading it into memory as
69
+ // base64 was a third larger again (BUGS-2026-07 M22)
70
+ await ctx.client.call<
71
+ { bucket: string; key: string; destPath: string },
72
+ { path?: string }
73
+ >(["s3", "download"], {
74
+ bucket: input.bucket,
75
+ key: archiveKey,
76
+ destPath: archivePath,
77
+ });
78
+
79
+ // Verify the downloaded archive against the checksum recorded at snapshot
80
+ // creation time. Do this before extracting so a corrupted or tampered
81
+ // archive never reaches the target directory.
82
+ if (metadata.checksum) {
83
+ const actualChecksum = await sha256File(archivePath);
84
+ if (actualChecksum !== metadata.checksum) {
85
+ throw new Error(
86
+ `Checksum mismatch for snapshot ${input.id}: expected ${metadata.checksum}, got ${actualChecksum}`
87
+ );
88
+ }
89
+ }
90
+
91
+ const downloadDuration = Date.now() - downloadStart;
92
+
93
+ // Extract archive into the caller-provided target directory.
94
+ const extractStart = Date.now();
95
+ const targetPath = input.targetPath;
96
+
97
+ if (!existsSync(targetPath)) {
98
+ mkdirSync(targetPath, { recursive: true });
99
+ }
100
+
101
+ // Check for existing files if not overwriting
102
+ if (!input.overwrite) {
103
+ // The archive stores each repository under the base name of its path (createArchive), not
104
+ // under its package name: before, the guard checked the package name, so an existing working
105
+ // copy was overwritten with overwrite: false (deep dive DATA-2)
106
+ for (const repo of metadata.repositories) {
107
+ const repoPath = join(targetPath, basename(repo.path));
108
+ if (existsSync(repoPath)) {
109
+ throw new Error(
110
+ `Target path already exists: ${repoPath}. Use overwrite: true to replace.`
111
+ );
112
+ }
113
+ }
114
+ }
115
+
116
+ // Extract into a staging folder in the target, then move each top-level folder into place.
117
+ // With overwrite: true, a restored folder replaces the existing one: before, the archive
118
+ // was extracted over it, and files that the snapshot does not hold stayed. A failed
119
+ // extraction leaves the target as it was.
120
+ const staging = mkdtempSync(join(targetPath, ".snapshot-restore-"));
121
+ try {
122
+ await tar.extract({
123
+ file: archivePath,
124
+ cwd: staging,
125
+ });
126
+ for (const entry of readdirSync(staging)) {
127
+ const destination = join(targetPath, entry);
128
+ if (existsSync(destination)) {
129
+ if (!input.overwrite) {
130
+ throw new Error(`Target path already exists: ${destination}. Use overwrite: true to replace.`);
131
+ }
132
+ rmSync(destination, { recursive: true, force: true });
133
+ }
134
+ renameSync(join(staging, entry), destination);
135
+ }
136
+ } finally {
137
+ rmSync(staging, { recursive: true, force: true });
138
+ }
139
+
140
+ const extractDuration = Date.now() - extractStart;
141
+
142
+ // Restore stashes if available
143
+ const restoredPaths: string[] = [];
144
+ for (const repo of metadata.repositories) {
145
+ const repoPath = join(targetPath, basename(repo.path));
146
+ if (existsSync(repoPath)) {
147
+ restoredPaths.push(repoPath);
148
+ }
149
+ }
150
+
151
+ return {
152
+ success: true,
153
+ metadata,
154
+ restoredPaths,
155
+ downloadDuration,
156
+ extractDuration,
157
+ };
158
+ } finally {
159
+ // Only ever remove the scratch download directory — never the extraction
160
+ // target. The `workDir !== input.targetPath` guard is belt-and-suspenders:
161
+ // targetPath is required and distinct from the temp scratch dir.
162
+ if (existsSync(workDir) && workDir !== input.targetPath) {
163
+ rmSync(workDir, { recursive: true, force: true });
164
+ }
165
+ }
166
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Shared S3 lookup helpers for snapshot procedures.
3
+ *
4
+ * Snapshot objects are stored under keys of the form
5
+ * snapshots/<name>/<id>.tar.gz
6
+ * snapshots/<name>/<id>.metadata.json
7
+ * where <id> already embeds <name>, a timestamp and a random suffix, so the
8
+ * final path segment (basename) of a key uniquely identifies a snapshot file.
9
+ *
10
+ * These helpers replace the previous "single s3.list + key.includes(id)"
11
+ * approach, which (a) missed snapshots once a bucket held more than 1000
12
+ * objects (S3 truncates and requires continuation), and (b) matched by
13
+ * substring, so an id that is a substring of a different snapshot's id could
14
+ * resolve to — and, for delete, remove — the wrong files.
15
+ */
16
+
17
+ import type { ProcedureContext } from "@procedures/client";
18
+
19
+ export interface S3ListedObject {
20
+ key: string;
21
+ /** The time of the last write (ISO 8601), as s3.list gives it */
22
+ lastModified?: string;
23
+ }
24
+
25
+ interface S3ListResult {
26
+ contents: S3ListedObject[];
27
+ isTruncated: boolean;
28
+ nextContinuationToken?: string;
29
+ }
30
+
31
+ /**
32
+ * List every object under `prefix`, following S3 pagination via
33
+ * continuationToken until the listing is no longer truncated.
34
+ */
35
+ export async function listAllObjects(
36
+ ctx: ProcedureContext,
37
+ bucket: string,
38
+ prefix: string
39
+ ): Promise<S3ListedObject[]> {
40
+ const all: S3ListedObject[] = [];
41
+ let continuationToken: string | undefined;
42
+
43
+ do {
44
+ const result = await ctx.client.call<
45
+ { bucket: string; prefix?: string; continuationToken?: string },
46
+ S3ListResult
47
+ >(["s3", "list"], {
48
+ bucket,
49
+ prefix,
50
+ ...(continuationToken ? { continuationToken } : {}),
51
+ });
52
+
53
+ all.push(...result.contents);
54
+ continuationToken = result.isTruncated ? result.nextContinuationToken : undefined;
55
+ } while (continuationToken);
56
+
57
+ return all;
58
+ }
59
+
60
+ /** Final path segment of an S3 key. */
61
+ function basenameOf(key: string): string {
62
+ const idx = key.lastIndexOf("/");
63
+ return idx === -1 ? key : key.slice(idx + 1);
64
+ }
65
+
66
+ /**
67
+ * Find the single object whose basename is exactly `${id}${suffix}`.
68
+ *
69
+ * Matching is by exact basename equality, never substring, so an id that is a
70
+ * substring of a different snapshot's id can never resolve to the wrong file.
71
+ */
72
+ export function findSnapshotKey(
73
+ objects: S3ListedObject[],
74
+ id: string,
75
+ suffix: string
76
+ ): string | undefined {
77
+ const target = `${id}${suffix}`;
78
+ return objects.find((obj) => basenameOf(obj.key) === target)?.key;
79
+ }
80
+
81
+ /**
82
+ * All object keys belonging to snapshot `id` (archive + metadata), matched by
83
+ * exact basename so delete can never remove a different snapshot's files.
84
+ */
85
+ export function findSnapshotKeys(objects: S3ListedObject[], id: string): string[] {
86
+ const targets = new Set([`${id}.tar.gz`, `${id}.metadata.json`]);
87
+ return objects.filter((obj) => targets.has(basenameOf(obj.key))).map((obj) => obj.key);
88
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Importing this file registers the procedures of @procedures/snapshot in the global registry
3
+ * (DECISIONS-2026-10, "Every package root registers its procedures"). The procedures as data
4
+ * are in `module.ts`, which registers nothing: a host with its own registry uses that one.
5
+ */
6
+
7
+ import { registerProcedures } from "@procedures/client";
8
+ import { snapshotModule } from "./module.js";
9
+
10
+ export * from "./module.js";
11
+
12
+ export function registerSnapshotProcedures(): void {
13
+ registerProcedures(snapshotModule.procedures);
14
+ }
15
+
16
+ // Auto-register when this module is loaded
17
+ registerSnapshotProcedures();
package/src/types.ts ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Type definitions for client-snapshot procedures
3
+ *
4
+ * Environment snapshot/restore for testing and recovery.
5
+ * Supports light/medium/heavy presets for different capture levels.
6
+ */
7
+
8
+ import { z } from "zod";
9
+
10
+ // =============================================================================
11
+ // Snapshot Presets
12
+ // =============================================================================
13
+
14
+ export type SnapshotPreset = "light" | "medium" | "heavy";
15
+
16
+ export const SnapshotPresetSchema: z.ZodEnum<{ light: "light"; medium: "medium"; heavy: "heavy" }> = z.enum(["light", "medium", "heavy"]);
17
+
18
+ /**
19
+ * Preset descriptions:
20
+ * - light: Git repos only (.git, working tree, branches, stashes) ~500MB
21
+ * - medium: + node_modules + pnpm lockfiles ~2GB
22
+ * - heavy: + pnpm store + global installs + configs ~10GB+
23
+ */
24
+
25
+ // =============================================================================
26
+ // Repository Info
27
+ // =============================================================================
28
+
29
+ export interface RepositoryInfo {
30
+ /** Repository path relative to snapshot root */
31
+ path: string;
32
+ /** Package name from package.json */
33
+ name: string;
34
+ /** Current branch */
35
+ branch: string;
36
+ /** HEAD commit hash */
37
+ commit: string;
38
+ /** Whether working tree is dirty */
39
+ dirty: boolean;
40
+ /** Number of stashes */
41
+ stashCount: number;
42
+ /** Remote URL */
43
+ remoteUrl?: string | undefined;
44
+ /** Commits ahead of remote */
45
+ ahead: number;
46
+ /** Commits behind remote */
47
+ behind: number;
48
+ }
49
+
50
+ // =============================================================================
51
+ // Snapshot Metadata
52
+ // =============================================================================
53
+
54
+ export interface SnapshotMetadata {
55
+ /** Unique snapshot ID */
56
+ id: string;
57
+ /** Human-readable name */
58
+ name: string;
59
+ /** Snapshot preset used */
60
+ preset: SnapshotPreset;
61
+ /** Creation timestamp (ISO string) */
62
+ createdAt: string;
63
+ /** Environment info */
64
+ environment: {
65
+ /** Operating system (win32, darwin, linux) */
66
+ os: string;
67
+ /** Node.js version */
68
+ nodeVersion: string;
69
+ /** pnpm version */
70
+ pnpmVersion: string;
71
+ /** Username who created snapshot */
72
+ username: string;
73
+ /** Hostname */
74
+ hostname: string;
75
+ };
76
+ /** List of repositories in snapshot */
77
+ repositories: RepositoryInfo[];
78
+ /** Archive checksum (SHA-256) */
79
+ checksum: string;
80
+ /** Compressed archive size in bytes */
81
+ archiveSize: number;
82
+ /** Optional description */
83
+ description?: string | undefined;
84
+ }
85
+
86
+ // =============================================================================
87
+ // snapshot.create Types
88
+ // =============================================================================
89
+
90
+ export const SnapshotCreateInputSchema: z.ZodObject<{
91
+ name: z.ZodString;
92
+ preset: z.ZodDefault<z.ZodEnum<{ light: "light"; medium: "medium"; heavy: "heavy" }>>;
93
+ bucket: z.ZodString;
94
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
95
+ description: z.ZodOptional<z.ZodString>;
96
+ }> = z.object({
97
+ /** Snapshot name (used in S3 key and metadata) */
98
+ name: z.string().describe("Snapshot name (used in S3 key and metadata)"),
99
+ /** Capture preset (default: medium) */
100
+ preset: SnapshotPresetSchema.default("medium").describe("Capture preset (default: medium)"),
101
+ /** S3 bucket for storage */
102
+ bucket: z.string().describe("S3 bucket for storage"),
103
+ /** Specific paths to include (default: all repos in ecosystem) */
104
+ paths: z.array(z.string()).optional().describe("Specific paths to include (default: all repos in ecosystem)"),
105
+ /** Optional description */
106
+ description: z.string().optional().describe("Optional description"),
107
+ });
108
+
109
+ export type SnapshotCreateInput = z.infer<typeof SnapshotCreateInputSchema>;
110
+
111
+ export interface SnapshotCreateOutput {
112
+ /** Snapshot ID */
113
+ id: string;
114
+ /** S3 location */
115
+ location: string;
116
+ /** Metadata */
117
+ metadata: SnapshotMetadata;
118
+ /** Upload duration in ms */
119
+ uploadDuration: number;
120
+ }
121
+
122
+ // =============================================================================
123
+ // snapshot.restore Types
124
+ // =============================================================================
125
+
126
+ export const SnapshotRestoreInputSchema: z.ZodObject<{
127
+ id: z.ZodString;
128
+ bucket: z.ZodString;
129
+ targetPath: z.ZodString;
130
+ overwrite: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
131
+ }> = z.object({
132
+ /** Snapshot ID to restore */
133
+ id: z.string().describe("Snapshot ID to restore"),
134
+ /** S3 bucket containing snapshot */
135
+ bucket: z.string().describe("S3 bucket containing snapshot"),
136
+ /**
137
+ * Destination directory the snapshot is extracted into. Required: restore
138
+ * must never fall back to a temporary scratch directory, because that
139
+ * scratch directory is deleted on cleanup and would wipe the restored files.
140
+ */
141
+ targetPath: z.string().describe("Destination directory the snapshot is extracted into. Required: restore must never fall back to a temporary scratch directory, because that scratch directory is deleted on cleanup and would wipe the restored files."),
142
+ /** Overwrite existing files (default: false) */
143
+ overwrite: z.boolean().optional().default(false).describe("Overwrite existing files (default: false)"),
144
+ });
145
+
146
+ export type SnapshotRestoreInput = z.infer<typeof SnapshotRestoreInputSchema>;
147
+
148
+ export interface SnapshotRestoreOutput {
149
+ /** Whether restore succeeded */
150
+ success: boolean;
151
+ /** Restored snapshot metadata */
152
+ metadata: SnapshotMetadata;
153
+ /** Paths that were restored */
154
+ restoredPaths: string[];
155
+ /** Download duration in ms */
156
+ downloadDuration: number;
157
+ /** Extract duration in ms */
158
+ extractDuration: number;
159
+ }
160
+
161
+ // =============================================================================
162
+ // snapshot.list Types
163
+ // =============================================================================
164
+
165
+ export const SnapshotListInputSchema: z.ZodObject<{
166
+ bucket: z.ZodString;
167
+ prefix: z.ZodOptional<z.ZodString>;
168
+ maxResults: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
169
+ }> = z.object({
170
+ /** S3 bucket to list */
171
+ bucket: z.string().describe("S3 bucket to list"),
172
+ /** Filter by name prefix */
173
+ prefix: z.string().optional().describe("Filter by name prefix"),
174
+ /** Maximum results (default: 100) */
175
+ maxResults: z.number().optional().default(100).describe("Maximum results (default: 100)"),
176
+ });
177
+
178
+ export type SnapshotListInput = z.infer<typeof SnapshotListInputSchema>;
179
+
180
+ export interface SnapshotListEntry {
181
+ /** Snapshot ID */
182
+ id: string;
183
+ /** Snapshot name */
184
+ name: string;
185
+ /** Preset used */
186
+ preset: SnapshotPreset;
187
+ /** Creation date */
188
+ createdAt: string;
189
+ /** Archive size in bytes */
190
+ size: number;
191
+ /** OS it was created on */
192
+ os: string;
193
+ }
194
+
195
+ export interface SnapshotListOutput {
196
+ /** List of snapshots */
197
+ snapshots: SnapshotListEntry[];
198
+ /** Total count */
199
+ count: number;
200
+ }
201
+
202
+ // =============================================================================
203
+ // snapshot.diff Types
204
+ // =============================================================================
205
+
206
+ export const SnapshotDiffInputSchema: z.ZodObject<{
207
+ id: z.ZodString;
208
+ bucket: z.ZodString;
209
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
210
+ }> = z.object({
211
+ /** Snapshot ID to compare against */
212
+ id: z.string().describe("Snapshot ID to compare against"),
213
+ /** S3 bucket containing snapshot */
214
+ bucket: z.string().describe("S3 bucket containing snapshot"),
215
+ /** Specific paths to compare (default: all) */
216
+ paths: z.array(z.string()).optional().describe("Specific paths to compare (default: all)"),
217
+ });
218
+
219
+ export type SnapshotDiffInput = z.infer<typeof SnapshotDiffInputSchema>;
220
+
221
+ export interface SnapshotDiffRepository {
222
+ /** Repository path */
223
+ path: string;
224
+ /** Branch difference (current vs snapshot) */
225
+ branchDiff?: { current: string; snapshot: string } | undefined;
226
+ /** Commit difference */
227
+ commitDiff?: { current: string; snapshot: string } | undefined;
228
+ /** Files changed since snapshot */
229
+ filesChanged: number;
230
+ /** New stashes since snapshot */
231
+ newStashes: number;
232
+ }
233
+
234
+ export interface SnapshotDiffOutput {
235
+ /** Snapshot metadata for comparison */
236
+ snapshotMetadata: SnapshotMetadata;
237
+ /** Per-repository differences */
238
+ repositories: SnapshotDiffRepository[];
239
+ /** Overall summary */
240
+ summary: {
241
+ /** Total repos with differences */
242
+ reposChanged: number;
243
+ /** Total files changed */
244
+ totalFilesChanged: number;
245
+ /** Whether current state matches snapshot */
246
+ isMatch: boolean;
247
+ };
248
+ }
249
+
250
+ // =============================================================================
251
+ // snapshot.delete Types
252
+ // =============================================================================
253
+
254
+ export const SnapshotDeleteInputSchema: z.ZodObject<{
255
+ id: z.ZodString;
256
+ bucket: z.ZodString;
257
+ }> = z.object({
258
+ /** Snapshot ID to delete */
259
+ id: z.string().describe("Snapshot ID to delete"),
260
+ /** S3 bucket containing snapshot */
261
+ bucket: z.string().describe("S3 bucket containing snapshot"),
262
+ });
263
+
264
+ export type SnapshotDeleteInput = z.infer<typeof SnapshotDeleteInputSchema>;
265
+
266
+ export interface SnapshotDeleteOutput {
267
+ /** Whether deletion succeeded */
268
+ deleted: boolean;
269
+ /** Deleted snapshot ID */
270
+ id: string;
271
+ }
272
+
273
+ // =============================================================================
274
+ // Output schemas (roadmap 2.1)
275
+ // =============================================================================
276
+
277
+ // Loose objects: a field that a schema does not name stays in the result (a strict schema would
278
+ // remove it when the output is validated).
279
+ //
280
+ // The metadata of a snapshot comes back from S3, where an older version of this package can have
281
+ // written it with fewer fields. A schema that required each field of today's type would turn a
282
+ // restore that worked into a validation error, so the metadata parts require only the id. The
283
+ // casts say that these schemas are more lenient than the types on purpose.
284
+
285
+ const repositoryInfo = z.looseObject({
286
+ path: z.string().optional().describe("The repository folder, relative to the snapshot root"),
287
+ name: z.string().optional().describe("The package name"),
288
+ branch: z.string().optional().describe("The branch at the time of the snapshot"),
289
+ commit: z.string().optional().describe("The HEAD commit at the time of the snapshot"),
290
+ dirty: z.boolean().optional().describe("True when the working tree had changes"),
291
+ stashCount: z.number().optional().describe("The number of stashes"),
292
+ remoteUrl: z.string().optional().describe("The remote URL"),
293
+ ahead: z.number().optional().describe("The commits that the remote did not have"),
294
+ behind: z.number().optional().describe("The commits of the remote that the branch did not have"),
295
+ });
296
+
297
+ const snapshotMetadata = z
298
+ .looseObject({
299
+ id: z.string().describe("The snapshot id"),
300
+ name: z.string().optional().describe("The name of the snapshot"),
301
+ preset: z.string().optional().describe("The preset: light, medium or heavy"),
302
+ createdAt: z.string().optional().describe("The time of the snapshot (ISO 8601)"),
303
+ environment: z
304
+ .looseObject({
305
+ os: z.string().optional().describe("The operating system"),
306
+ nodeVersion: z.string().optional().describe("The Node.js version"),
307
+ pnpmVersion: z.string().optional().describe("The pnpm version"),
308
+ username: z.string().optional().describe("The user who made the snapshot"),
309
+ hostname: z.string().optional().describe("The host name"),
310
+ })
311
+ .optional()
312
+ .describe("The environment of the snapshot"),
313
+ repositories: z.array(repositoryInfo).optional().describe("The repositories in the snapshot"),
314
+ checksum: z.string().optional().describe("The SHA-256 checksum of the archive"),
315
+ archiveSize: z.number().optional().describe("The size of the compressed archive, in bytes"),
316
+ description: z.string().optional().describe("The description"),
317
+ })
318
+ .describe("The metadata of the snapshot");
319
+
320
+ export const SnapshotCreateOutputSchema: z.ZodType<SnapshotCreateOutput> = z.looseObject({
321
+ id: z.string().describe("The snapshot id"),
322
+ location: z.string().describe("The S3 location of the archive"),
323
+ metadata: snapshotMetadata,
324
+ uploadDuration: z.number().describe("The time of the upload, in milliseconds"),
325
+ }) as unknown as z.ZodType<SnapshotCreateOutput>;
326
+
327
+ export const SnapshotRestoreOutputSchema: z.ZodType<SnapshotRestoreOutput> = z.looseObject({
328
+ success: z.boolean().describe("True when the restore succeeded"),
329
+ metadata: snapshotMetadata,
330
+ restoredPaths: z.array(z.string()).describe("The folders that the restore wrote"),
331
+ downloadDuration: z.number().describe("The time of the download, in milliseconds"),
332
+ extractDuration: z.number().describe("The time of the extraction, in milliseconds"),
333
+ }) as unknown as z.ZodType<SnapshotRestoreOutput>;
334
+
335
+ export const SnapshotListOutputSchema: z.ZodType<SnapshotListOutput> = z.looseObject({
336
+ snapshots: z
337
+ .array(
338
+ z.looseObject({
339
+ id: z.string().describe("The snapshot id"),
340
+ name: z.string().optional().describe("The name of the snapshot"),
341
+ preset: z.string().optional().describe("The preset"),
342
+ createdAt: z.string().optional().describe("The time of the snapshot (ISO 8601)"),
343
+ size: z.number().optional().describe("The size of the archive, in bytes"),
344
+ os: z.string().optional().describe("The operating system of the snapshot"),
345
+ })
346
+ )
347
+ .describe("The snapshots, newest first"),
348
+ count: z.number().describe("The number of snapshots"),
349
+ }) as unknown as z.ZodType<SnapshotListOutput>;
350
+
351
+ const pair = z.looseObject({
352
+ current: z.string().describe("The value now"),
353
+ snapshot: z.string().describe("The value in the snapshot"),
354
+ });
355
+
356
+ export const SnapshotDiffOutputSchema: z.ZodType<SnapshotDiffOutput> = z.looseObject({
357
+ snapshotMetadata,
358
+ repositories: z
359
+ .array(
360
+ z.looseObject({
361
+ path: z.string().describe("The repository folder"),
362
+ branchDiff: pair.optional().describe("The branch now and in the snapshot, when they differ"),
363
+ commitDiff: pair.optional().describe("The commit now and in the snapshot, when they differ"),
364
+ filesChanged: z.number().describe("The files changed since the snapshot"),
365
+ newStashes: z.number().describe("The stashes made since the snapshot"),
366
+ })
367
+ )
368
+ .describe("The differences of each repository"),
369
+ summary: z
370
+ .looseObject({
371
+ reposChanged: z.number().describe("The repositories with differences"),
372
+ totalFilesChanged: z.number().describe("The files changed in all repositories"),
373
+ isMatch: z.boolean().describe("True when the current state matches the snapshot"),
374
+ })
375
+ .describe("The summary"),
376
+ }) as unknown as z.ZodType<SnapshotDiffOutput>;
377
+
378
+ export const SnapshotDeleteOutputSchema: z.ZodType<SnapshotDeleteOutput> = z.looseObject({
379
+ deleted: z.boolean().describe("True when the snapshot was deleted"),
380
+ id: z.string().describe("The snapshot id"),
381
+ });