crewly 1.11.5 → 1.11.6

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/dist/backend/backend/src/constants.d.ts +22 -1
  2. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  3. package/dist/backend/backend/src/constants.js +22 -1
  4. package/dist/backend/backend/src/constants.js.map +1 -1
  5. package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts +90 -0
  6. package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
  7. package/dist/backend/backend/src/services/backup/backup-archive.service.js +309 -0
  8. package/dist/backend/backend/src/services/backup/backup-archive.service.js.map +1 -0
  9. package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
  10. package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
  11. package/dist/backend/backend/src/services/backup/backup-cloud.client.js +134 -0
  12. package/dist/backend/backend/src/services/backup/backup-cloud.client.js.map +1 -0
  13. package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts +78 -0
  14. package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
  15. package/dist/backend/backend/src/services/backup/backup-restore.service.js +358 -0
  16. package/dist/backend/backend/src/services/backup/backup-restore.service.js.map +1 -0
  17. package/dist/backend/backend/src/services/backup/backup.types.d.ts +163 -0
  18. package/dist/backend/backend/src/services/backup/backup.types.d.ts.map +1 -0
  19. package/dist/backend/backend/src/services/backup/backup.types.js +13 -0
  20. package/dist/backend/backend/src/services/backup/backup.types.js.map +1 -0
  21. package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts +29 -2
  22. package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -1
  23. package/dist/backend/backend/src/services/cloud/cloud-sync.service.js +97 -13
  24. package/dist/backend/backend/src/services/cloud/cloud-sync.service.js.map +1 -1
  25. package/dist/cli/backend/src/constants.d.ts +22 -1
  26. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  27. package/dist/cli/backend/src/constants.js +22 -1
  28. package/dist/cli/backend/src/constants.js.map +1 -1
  29. package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts +70 -0
  30. package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts.map +1 -0
  31. package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js +427 -0
  32. package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js.map +1 -0
  33. package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts +90 -0
  34. package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
  35. package/dist/cli/backend/src/services/backup/backup-archive.service.js +309 -0
  36. package/dist/cli/backend/src/services/backup/backup-archive.service.js.map +1 -0
  37. package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
  38. package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
  39. package/dist/cli/backend/src/services/backup/backup-cloud.client.js +134 -0
  40. package/dist/cli/backend/src/services/backup/backup-cloud.client.js.map +1 -0
  41. package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts +78 -0
  42. package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
  43. package/dist/cli/backend/src/services/backup/backup-restore.service.js +358 -0
  44. package/dist/cli/backend/src/services/backup/backup-restore.service.js.map +1 -0
  45. package/dist/cli/backend/src/services/backup/backup.types.d.ts +163 -0
  46. package/dist/cli/backend/src/services/backup/backup.types.d.ts.map +1 -0
  47. package/dist/cli/backend/src/services/backup/backup.types.js +13 -0
  48. package/dist/cli/backend/src/services/backup/backup.types.js.map +1 -0
  49. package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts +410 -0
  50. package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts.map +1 -0
  51. package/dist/cli/backend/src/services/cloud/cloud-client.service.js +863 -0
  52. package/dist/cli/backend/src/services/cloud/cloud-client.service.js.map +1 -0
  53. package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts +292 -0
  54. package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -0
  55. package/dist/cli/backend/src/services/cloud/cloud-sync.service.js +1093 -0
  56. package/dist/cli/backend/src/services/cloud/cloud-sync.service.js.map +1 -0
  57. package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts +328 -0
  58. package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -0
  59. package/dist/cli/backend/src/services/cloud/cloud-sync.types.js +171 -0
  60. package/dist/cli/backend/src/services/cloud/cloud-sync.types.js.map +1 -0
  61. package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts +89 -0
  62. package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts.map +1 -0
  63. package/dist/cli/backend/src/services/cloud/device-identity.service.js +148 -0
  64. package/dist/cli/backend/src/services/cloud/device-identity.service.js.map +1 -0
  65. package/dist/cli/backend/src/services/user/user-identity.service.d.ts +86 -0
  66. package/dist/cli/backend/src/services/user/user-identity.service.d.ts.map +1 -0
  67. package/dist/cli/backend/src/services/user/user-identity.service.js +190 -0
  68. package/dist/cli/backend/src/services/user/user-identity.service.js.map +1 -0
  69. package/dist/cli/cli/src/commands/backup.d.ts +31 -0
  70. package/dist/cli/cli/src/commands/backup.d.ts.map +1 -0
  71. package/dist/cli/cli/src/commands/backup.js +280 -0
  72. package/dist/cli/cli/src/commands/backup.js.map +1 -0
  73. package/dist/cli/cli/src/index.js +10 -0
  74. package/dist/cli/cli/src/index.js.map +1 -1
  75. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Backup Cloud Client (P3)
3
+ *
4
+ * OSS-side HTTP client for the Pro-gated cloud backup service
5
+ * (`/api/cloud/backup`). Used by `crewly backup push/pull/list` to park an
6
+ * archive in the cloud and pull it back on another machine.
7
+ *
8
+ * Transport-only: it streams files to/from the cloud and surfaces a friendly
9
+ * upgrade error on 402. Auth (access token) + base URL come from
10
+ * CloudClientService; both are injected so this is testable without the cloud.
11
+ *
12
+ * @module services/backup/backup-cloud.client
13
+ */
14
+ /** Thrown when the account isn't Pro (HTTP 402). */
15
+ export declare class BackupNotProError extends Error {
16
+ readonly code = "upgrade_required";
17
+ constructor(message?: string);
18
+ }
19
+ /** Thrown on any other cloud backup HTTP failure. */
20
+ export declare class BackupCloudError extends Error {
21
+ readonly status?: number | undefined;
22
+ constructor(message: string, status?: number | undefined);
23
+ }
24
+ /** A cloud snapshot as returned by the service (client-facing view). */
25
+ export interface CloudBackupItem {
26
+ backupId: string;
27
+ deviceName: string | null;
28
+ deviceId: string | null;
29
+ sizeBytes: number;
30
+ sha256: string | null;
31
+ cryptoMode: 'sse' | 'none';
32
+ createdAt: string;
33
+ }
34
+ /** Metadata sent with an upload. */
35
+ export interface PushMeta {
36
+ backupId?: string;
37
+ deviceName?: string | null;
38
+ deviceId?: string | null;
39
+ sha256?: string | null;
40
+ }
41
+ /** HTTP client for the cloud backup endpoints. */
42
+ export declare class BackupCloudClient {
43
+ private readonly opts;
44
+ private readonly fetchImpl;
45
+ constructor(opts: {
46
+ baseUrl: string;
47
+ token: string;
48
+ fetchImpl?: typeof fetch;
49
+ });
50
+ private url;
51
+ private authHeaders;
52
+ /** List this account's cloud snapshots (newest first). */
53
+ list(): Promise<CloudBackupItem[]>;
54
+ /**
55
+ * Upload an archive to the cloud. Streams the file with its Content-Length so
56
+ * the service can enforce quota before accepting the blob.
57
+ *
58
+ * @param archivePath - Local archive (.tar.gz)
59
+ * @param meta - Optional backupId + device/sha metadata
60
+ * @returns The created snapshot record
61
+ */
62
+ push(archivePath: string, meta?: PushMeta): Promise<CloudBackupItem>;
63
+ /**
64
+ * Download a snapshot to `destPath` (streamed).
65
+ *
66
+ * @param backupId - Snapshot id
67
+ * @param destPath - Local file to write
68
+ */
69
+ pull(backupId: string, destPath: string): Promise<void>;
70
+ /** Delete a cloud snapshot. */
71
+ remove(backupId: string): Promise<void>;
72
+ /** Map non-2xx responses to typed errors (402 → upgrade). */
73
+ private assertOk;
74
+ }
75
+ //# sourceMappingURL=backup-cloud.client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup-cloud.client.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/backup/backup-cloud.client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH,oDAAoD;AACpD,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,IAAI,sBAAsB;gBACvB,OAAO,SAA+E;CAInG;AAED,qDAAqD;AACrD,qBAAa,gBAAiB,SAAQ,KAAK;aAGvB,MAAM,CAAC,EAAE,MAAM;gBAD/B,OAAO,EAAE,MAAM,EACC,MAAM,CAAC,EAAE,MAAM,YAAA;CAKlC;AAED,wEAAwE;AACxE,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,UAAU,EAAE,KAAK,GAAG,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,oCAAoC;AACpC,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,kDAAkD;AAClD,qBAAa,iBAAiB;IAI1B,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHvB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAGtB,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;KAAE;IAKrF,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,WAAW;IAInB,0DAA0D;IACpD,IAAI,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;IAOxC;;;;;;;OAOG;IACG,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,GAAE,QAAa,GAAG,OAAO,CAAC,eAAe,CAAC;IAsB9E;;;;;OAKG;IACG,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ7D,+BAA+B;IACzB,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ7C,6DAA6D;YAC/C,QAAQ;CAoBvB"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Backup Cloud Client (P3)
3
+ *
4
+ * OSS-side HTTP client for the Pro-gated cloud backup service
5
+ * (`/api/cloud/backup`). Used by `crewly backup push/pull/list` to park an
6
+ * archive in the cloud and pull it back on another machine.
7
+ *
8
+ * Transport-only: it streams files to/from the cloud and surfaces a friendly
9
+ * upgrade error on 402. Auth (access token) + base URL come from
10
+ * CloudClientService; both are injected so this is testable without the cloud.
11
+ *
12
+ * @module services/backup/backup-cloud.client
13
+ */
14
+ import { createReadStream, createWriteStream } from 'node:fs';
15
+ import * as fs from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ import { Readable } from 'node:stream';
18
+ import { pipeline } from 'node:stream/promises';
19
+ /** Thrown when the account isn't Pro (HTTP 402). */
20
+ export class BackupNotProError extends Error {
21
+ code = 'upgrade_required';
22
+ constructor(message = 'Cloud backup requires a Pro plan. Upgrade at https://crewlyai.com/pricing.') {
23
+ super(message);
24
+ this.name = 'BackupNotProError';
25
+ }
26
+ }
27
+ /** Thrown on any other cloud backup HTTP failure. */
28
+ export class BackupCloudError extends Error {
29
+ status;
30
+ constructor(message, status) {
31
+ super(message);
32
+ this.status = status;
33
+ this.name = 'BackupCloudError';
34
+ }
35
+ }
36
+ /** HTTP client for the cloud backup endpoints. */
37
+ export class BackupCloudClient {
38
+ opts;
39
+ fetchImpl;
40
+ constructor(opts) {
41
+ this.opts = opts;
42
+ this.fetchImpl = opts.fetchImpl ?? fetch;
43
+ }
44
+ url(suffix) {
45
+ return `${this.opts.baseUrl.replace(/\/$/, '')}/api/cloud/backup${suffix}`;
46
+ }
47
+ authHeaders(extra = {}) {
48
+ return { Authorization: `Bearer ${this.opts.token}`, ...extra };
49
+ }
50
+ /** List this account's cloud snapshots (newest first). */
51
+ async list() {
52
+ const res = await this.fetchImpl(this.url(''), { headers: this.authHeaders() });
53
+ await this.assertOk(res);
54
+ const json = (await res.json());
55
+ return json.data?.backups ?? [];
56
+ }
57
+ /**
58
+ * Upload an archive to the cloud. Streams the file with its Content-Length so
59
+ * the service can enforce quota before accepting the blob.
60
+ *
61
+ * @param archivePath - Local archive (.tar.gz)
62
+ * @param meta - Optional backupId + device/sha metadata
63
+ * @returns The created snapshot record
64
+ */
65
+ async push(archivePath, meta = {}) {
66
+ const stat = await fs.stat(archivePath);
67
+ const query = meta.backupId ? `?backupId=${encodeURIComponent(meta.backupId)}` : '';
68
+ const headers = this.authHeaders({
69
+ 'Content-Type': 'application/gzip',
70
+ 'Content-Length': String(stat.size),
71
+ ...(meta.deviceName ? { 'X-Device-Name': meta.deviceName } : {}),
72
+ ...(meta.deviceId ? { 'X-Device-Id': meta.deviceId } : {}),
73
+ ...(meta.sha256 ? { 'X-Backup-Sha256': meta.sha256 } : {}),
74
+ });
75
+ const res = await this.fetchImpl(this.url(query), {
76
+ method: 'POST',
77
+ headers,
78
+ body: createReadStream(archivePath),
79
+ // Node fetch requires this for a streaming request body.
80
+ duplex: 'half',
81
+ });
82
+ await this.assertOk(res);
83
+ const json = (await res.json());
84
+ return json.data;
85
+ }
86
+ /**
87
+ * Download a snapshot to `destPath` (streamed).
88
+ *
89
+ * @param backupId - Snapshot id
90
+ * @param destPath - Local file to write
91
+ */
92
+ async pull(backupId, destPath) {
93
+ const res = await this.fetchImpl(this.url(`/${encodeURIComponent(backupId)}`), { headers: this.authHeaders() });
94
+ await this.assertOk(res);
95
+ if (!res.body)
96
+ throw new BackupCloudError('Empty download response from cloud.');
97
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
98
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
99
+ }
100
+ /** Delete a cloud snapshot. */
101
+ async remove(backupId) {
102
+ const res = await this.fetchImpl(this.url(`/${encodeURIComponent(backupId)}`), {
103
+ method: 'DELETE',
104
+ headers: this.authHeaders(),
105
+ });
106
+ await this.assertOk(res);
107
+ }
108
+ /** Map non-2xx responses to typed errors (402 → upgrade). */
109
+ async assertOk(res) {
110
+ if (res.ok)
111
+ return;
112
+ if (res.status === 402) {
113
+ let msg;
114
+ try {
115
+ msg = (await res.json()).error;
116
+ }
117
+ catch {
118
+ /* ignore */
119
+ }
120
+ throw new BackupNotProError(msg);
121
+ }
122
+ let message = `Cloud backup request failed (HTTP ${res.status}).`;
123
+ try {
124
+ const j = (await res.json());
125
+ if (j.error)
126
+ message = j.error;
127
+ }
128
+ catch {
129
+ /* non-JSON body */
130
+ }
131
+ throw new BackupCloudError(message, res.status);
132
+ }
133
+ }
134
+ //# sourceMappingURL=backup-cloud.client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup-cloud.client.js","sourceRoot":"","sources":["../../../../../../backend/src/services/backup/backup-cloud.client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Backup Restore Service (P1)
3
+ *
4
+ * Restores a workspace archive (produced by BackupArchiveService) onto this
5
+ * machine to resume work after a machine dies / is replaced.
6
+ *
7
+ * Safety model:
8
+ * - `preview()` is non-destructive (dry-run) — reports the plan + conflicts.
9
+ * - `restore()` snapshots the CURRENT CREWLY_HOME first (rollback), refuses
10
+ * on overlapping ids unless mode='overwrite', then applies and — on any
11
+ * failure — rolls back from the snapshot.
12
+ * - device.json is NEVER in the archive, so the target keeps its own identity.
13
+ * - cron `nextRunAt` is reset to null so the scheduler recomputes from
14
+ * `cronExpression` (no stale fires); runtime/session state is wiped so
15
+ * agents start clean.
16
+ *
17
+ * See specs/2026-06-07-workspace-backup.md.
18
+ *
19
+ * @module services/backup/backup-restore.service
20
+ */
21
+ import { type ComponentLogger } from '../core/logger.service.js';
22
+ import { type RestoreOptions, type RestorePlan, type RestoreResult } from './backup.types.js';
23
+ /** Thrown by restore() when mode='abort' and the target has overlapping data. */
24
+ export declare class RestoreConflictError extends Error {
25
+ readonly plan: RestorePlan;
26
+ constructor(message: string, plan: RestorePlan);
27
+ }
28
+ /**
29
+ * Service that restores workspace backup archives.
30
+ */
31
+ export declare class BackupRestoreService {
32
+ private readonly logger;
33
+ constructor(logger?: ComponentLogger);
34
+ /**
35
+ * Non-destructive restore plan (dry-run): what would be created/overwritten,
36
+ * id conflicts, resolved project paths, and what's regenerated/discarded.
37
+ *
38
+ * @param options - Restore options (mode/pathMap affect conflict + path resolution)
39
+ * @returns The plan; never writes to disk
40
+ */
41
+ preview(options: RestoreOptions): Promise<RestorePlan>;
42
+ /**
43
+ * Restore the archive onto this machine.
44
+ *
45
+ * @param options - Restore options
46
+ * @returns Summary of what was restored
47
+ * @throws RestoreConflictError when mode='abort' and ids overlap
48
+ * @throws Error on integrity failure or unsupported schema (no changes applied)
49
+ */
50
+ restore(options: RestoreOptions): Promise<RestoreResult>;
51
+ /** Extract the archive to a fresh temp dir; returns its path. */
52
+ private extract;
53
+ /** Read + minimally validate the manifest from an extracted archive. */
54
+ private readManifest;
55
+ /** Verify every recorded file's sha256 against the extracted bytes. */
56
+ private verifyChecksums;
57
+ /** Build the dry-run plan (conflicts, resolved project paths, warnings). */
58
+ private buildPlan;
59
+ /** Copy every manifest global file from temp/home/* into CREWLY_HOME. */
60
+ private applyGlobals;
61
+ /** Copy each resolved project's `.crewly/` from the archive to its target path. */
62
+ private applyProjects;
63
+ /** Atomically swap chat.db into place (temp copy → rename). */
64
+ private applyChatDb;
65
+ /** Rewrite projects.json `path` entries using the resolved plan mapping. */
66
+ private rewriteProjectsJson;
67
+ /** Reset cron `nextRunAt` so the scheduler recomputes from cronExpression. */
68
+ private resetCron;
69
+ /** Remove runtime/session state so agents start clean on the restored machine. */
70
+ private wipeRuntime;
71
+ /** Recursively copy a directory tree, skipping top-level names in `skipTop`. */
72
+ private copyTree;
73
+ /** Recursive copy without the top-level skip filter. */
74
+ private copyTreeRec;
75
+ private pathExists;
76
+ private readJson;
77
+ }
78
+ //# sourceMappingURL=backup-restore.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup-restore.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/backup/backup-restore.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,OAAO,EAAiB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAChF,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,WAAW,EAEhB,KAAK,aAAa,EAEnB,MAAM,mBAAmB,CAAC;AAQ3B,iFAAiF;AACjF,qBAAa,oBAAqB,SAAQ,KAAK;aAG3B,IAAI,EAAE,WAAW;gBADjC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,WAAW;CAKpC;AAED;;GAEG;AACH,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;gBAE7B,MAAM,CAAC,EAAE,eAAe;IAIpC;;;;;;OAMG;IACG,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,WAAW,CAAC;IAW5D;;;;;;;OAOG;IACG,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IA4D9D,iEAAiE;YACnD,OAAO;IASrB,wEAAwE;YAC1D,YAAY;IAe1B,uEAAuE;YACzD,eAAe;IAiB7B,4EAA4E;YAC9D,SAAS;IAqDvB,yEAAyE;YAC3D,YAAY;IAc1B,mFAAmF;YACrE,aAAa;IAmB3B,+DAA+D;YACjD,WAAW;IAezB,4EAA4E;YAC9D,mBAAmB;IAgBjC,8EAA8E;YAChE,SAAS;IAgBvB,kFAAkF;YACpE,WAAW;IAQzB,gFAAgF;YAClE,QAAQ;IAYtB,wDAAwD;YAC1C,WAAW;YAUX,UAAU;YAIV,QAAQ;CAOvB"}
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Backup Restore Service (P1)
3
+ *
4
+ * Restores a workspace archive (produced by BackupArchiveService) onto this
5
+ * machine to resume work after a machine dies / is replaced.
6
+ *
7
+ * Safety model:
8
+ * - `preview()` is non-destructive (dry-run) — reports the plan + conflicts.
9
+ * - `restore()` snapshots the CURRENT CREWLY_HOME first (rollback), refuses
10
+ * on overlapping ids unless mode='overwrite', then applies and — on any
11
+ * failure — rolls back from the snapshot.
12
+ * - device.json is NEVER in the archive, so the target keeps its own identity.
13
+ * - cron `nextRunAt` is reset to null so the scheduler recomputes from
14
+ * `cronExpression` (no stale fires); runtime/session state is wiped so
15
+ * agents start clean.
16
+ *
17
+ * See specs/2026-06-07-workspace-backup.md.
18
+ *
19
+ * @module services/backup/backup-restore.service
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { createReadStream } from 'node:fs';
23
+ import * as fs from 'node:fs/promises';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import { extract as tarExtract } from 'tar';
27
+ import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
28
+ import { LoggerService } from '../core/logger.service.js';
29
+ import { BACKUP_SCHEMA_VERSION, } from './backup.types.js';
30
+ /** Runtime/session state wiped on restore so agents start clean (relative to home). */
31
+ const RUNTIME_WIPE = ['runtime.json', '.orchestrator-state', 'session-state.json'];
32
+ /** Cron files whose `nextRunAt` is reset so the scheduler recomputes. */
33
+ const CRON_FILES = ['recurring-checks.json', 'one-time-checks.json'];
34
+ /** Thrown by restore() when mode='abort' and the target has overlapping data. */
35
+ export class RestoreConflictError extends Error {
36
+ plan;
37
+ constructor(message, plan) {
38
+ super(message);
39
+ this.plan = plan;
40
+ this.name = 'RestoreConflictError';
41
+ }
42
+ }
43
+ /**
44
+ * Service that restores workspace backup archives.
45
+ */
46
+ export class BackupRestoreService {
47
+ logger;
48
+ constructor(logger) {
49
+ this.logger = logger ?? LoggerService.getInstance().createComponentLogger('BackupRestore');
50
+ }
51
+ /**
52
+ * Non-destructive restore plan (dry-run): what would be created/overwritten,
53
+ * id conflicts, resolved project paths, and what's regenerated/discarded.
54
+ *
55
+ * @param options - Restore options (mode/pathMap affect conflict + path resolution)
56
+ * @returns The plan; never writes to disk
57
+ */
58
+ async preview(options) {
59
+ const home = options.homePath ?? getCrewlyHomePath();
60
+ const temp = await this.extract(options.archivePath);
61
+ try {
62
+ const manifest = await this.readManifest(temp);
63
+ return await this.buildPlan(manifest, home, options);
64
+ }
65
+ finally {
66
+ await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
67
+ }
68
+ }
69
+ /**
70
+ * Restore the archive onto this machine.
71
+ *
72
+ * @param options - Restore options
73
+ * @returns Summary of what was restored
74
+ * @throws RestoreConflictError when mode='abort' and ids overlap
75
+ * @throws Error on integrity failure or unsupported schema (no changes applied)
76
+ */
77
+ async restore(options) {
78
+ const home = options.homePath ?? getCrewlyHomePath();
79
+ const mode = options.mode ?? 'abort';
80
+ const temp = await this.extract(options.archivePath);
81
+ try {
82
+ const manifest = await this.readManifest(temp);
83
+ if (manifest.schemaVersion > BACKUP_SCHEMA_VERSION) {
84
+ throw new Error(`Backup schemaVersion ${manifest.schemaVersion} is newer than supported ${BACKUP_SCHEMA_VERSION}; upgrade Crewly to restore it`);
85
+ }
86
+ await this.verifyChecksums(temp, manifest);
87
+ const plan = await this.buildPlan(manifest, home, options);
88
+ if (mode === 'abort' && (plan.conflicts.teams.length > 0 || plan.conflicts.projects.length > 0)) {
89
+ throw new RestoreConflictError(`Restore aborted: target already has ${plan.conflicts.teams.length} team(s) and ${plan.conflicts.projects.length} project(s) from this backup. Re-run with mode='overwrite' to replace them.`, plan);
90
+ }
91
+ // 1) Snapshot current home for rollback (best-effort full copy, sans backups/).
92
+ const rollbackSnapshotPath = path.join(home, 'backups', `pre-restore-${options.now.replace(/[:.]/g, '-')}`);
93
+ await fs.mkdir(home, { recursive: true });
94
+ await this.copyTree(home, rollbackSnapshotPath, new Set(['backups']));
95
+ try {
96
+ // 2) Apply
97
+ const restoredGlobalFiles = await this.applyGlobals(temp, home);
98
+ const restoredProjects = await this.applyProjects(temp, manifest, plan);
99
+ const chatDbRestored = await this.applyChatDb(temp, home, manifest);
100
+ await this.rewriteProjectsJson(home, plan);
101
+ await this.resetCron(home);
102
+ await this.wipeRuntime(home);
103
+ this.logger.info('Workspace restore applied', {
104
+ restoredGlobalFiles,
105
+ restoredProjects,
106
+ chatDbRestored,
107
+ rollbackSnapshotPath,
108
+ });
109
+ return { restoredGlobalFiles, restoredProjects, chatDbRestored, rollbackSnapshotPath, warnings: plan.warnings };
110
+ }
111
+ catch (applyErr) {
112
+ // 3) Rollback from the pre-restore snapshot.
113
+ this.logger.error('Restore failed mid-apply — rolling back', {
114
+ error: applyErr instanceof Error ? applyErr.message : String(applyErr),
115
+ });
116
+ await this.copyTree(rollbackSnapshotPath, home, new Set(['backups'])).catch(() => undefined);
117
+ throw applyErr;
118
+ }
119
+ }
120
+ finally {
121
+ await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
122
+ }
123
+ }
124
+ // -------------------------------------------------------------------------
125
+ // Internals
126
+ // -------------------------------------------------------------------------
127
+ /** Extract the archive to a fresh temp dir; returns its path. */
128
+ async extract(archivePath) {
129
+ if (!(await fs.stat(archivePath).catch(() => null))?.isFile()) {
130
+ throw new Error(`Backup archive not found: ${archivePath}`);
131
+ }
132
+ const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'crewly-restore-'));
133
+ await tarExtract({ file: archivePath, cwd: temp });
134
+ return temp;
135
+ }
136
+ /** Read + minimally validate the manifest from an extracted archive. */
137
+ async readManifest(temp) {
138
+ const raw = await fs.readFile(path.join(temp, 'manifest.json'), 'utf8').catch(() => null);
139
+ if (!raw)
140
+ throw new Error('Invalid backup: manifest.json missing');
141
+ let manifest;
142
+ try {
143
+ manifest = JSON.parse(raw);
144
+ }
145
+ catch {
146
+ throw new Error('Invalid backup: manifest.json is not valid JSON');
147
+ }
148
+ if (typeof manifest.schemaVersion !== 'number' || !Array.isArray(manifest.global)) {
149
+ throw new Error('Invalid backup: manifest is missing required fields');
150
+ }
151
+ return manifest;
152
+ }
153
+ /** Verify every recorded file's sha256 against the extracted bytes. */
154
+ async verifyChecksums(temp, manifest) {
155
+ const entries = [
156
+ ...manifest.global,
157
+ ...manifest.projects.flatMap((p) => p.files),
158
+ ];
159
+ if (manifest.chatDb.included && manifest.chatDb.sha256) {
160
+ entries.push({ path: 'chat.db', sha256: manifest.chatDb.sha256 });
161
+ }
162
+ for (const e of entries) {
163
+ const abs = path.join(temp, ...e.path.split('/'));
164
+ const actual = await sha256File(abs).catch(() => null);
165
+ if (actual !== e.sha256) {
166
+ throw new Error(`Backup integrity check failed for ${e.path} (checksum mismatch or missing)`);
167
+ }
168
+ }
169
+ }
170
+ /** Build the dry-run plan (conflicts, resolved project paths, warnings). */
171
+ async buildPlan(manifest, home, options) {
172
+ const warnings = [];
173
+ // Teams present in the backup (ids parsed from home/teams/<id>/...).
174
+ const backupTeamIds = new Set();
175
+ for (const g of manifest.global) {
176
+ const m = g.path.match(/^home\/teams\/([^/]+)\//);
177
+ if (m && m[1] !== 'orchestrator')
178
+ backupTeamIds.add(m[1]);
179
+ }
180
+ const targetTeamIds = new Set((await fs.readdir(path.join(home, 'teams'), { withFileTypes: true }).catch(() => []))
181
+ .filter((d) => d.isDirectory() && d.name !== 'orchestrator')
182
+ .map((d) => d.name));
183
+ const conflictTeams = [...backupTeamIds].filter((id) => targetTeamIds.has(id));
184
+ const targetProjectIds = new Set((await this.readJson(path.join(home, 'projects.json'), [])).map((p) => p.id));
185
+ const conflictProjects = manifest.projects.map((p) => p.id).filter((id) => targetProjectIds.has(id));
186
+ const projects = [];
187
+ for (const p of manifest.projects) {
188
+ const mapped = options.pathMap?.[p.sourcePath];
189
+ let targetPath = null;
190
+ if (mapped)
191
+ targetPath = mapped;
192
+ else if (await this.pathExists(p.sourcePath))
193
+ targetPath = p.sourcePath;
194
+ const targetExists = targetPath ? await this.pathExists(targetPath) : false;
195
+ if (!targetPath) {
196
+ warnings.push(`Project "${p.name}" (${p.sourcePath}) has no target path on this machine — re-clone ${p.git.remote ?? 'the repo'} and pass --map ${p.sourcePath}=<new-path> to restore its .crewly data.`);
197
+ }
198
+ else if (!targetExists) {
199
+ warnings.push(`Project "${p.name}" target path ${targetPath} does not exist yet — its .crewly will be created there.`);
200
+ }
201
+ projects.push({ id: p.id, name: p.name, sourcePath: p.sourcePath, targetPath, git: p.git, targetExists });
202
+ }
203
+ return {
204
+ ok: !(options.mode !== 'overwrite' && (conflictTeams.length > 0 || conflictProjects.length > 0)),
205
+ manifestCreatedAt: manifest.createdAt,
206
+ sourceHomePath: manifest.sourceHomePath,
207
+ conflicts: { teams: conflictTeams, projects: conflictProjects },
208
+ globalFileCount: manifest.global.length,
209
+ projects,
210
+ chatDbIncluded: manifest.chatDb.included,
211
+ regenerated: ['device.json (kept this machine\'s identity — not in backup)'],
212
+ discarded: [...RUNTIME_WIPE, 'in-flight cron nextRunAt (recomputed)'],
213
+ warnings,
214
+ };
215
+ }
216
+ /** Copy every manifest global file from temp/home/* into CREWLY_HOME. */
217
+ async applyGlobals(temp, home) {
218
+ let n = 0;
219
+ for (const g of (await this.readManifest(temp)).global) {
220
+ // g.path = 'home/<rel>'
221
+ const rel = g.path.replace(/^home\//, '');
222
+ const src = path.join(temp, ...g.path.split('/'));
223
+ const dest = path.join(home, ...rel.split('/'));
224
+ await fs.mkdir(path.dirname(dest), { recursive: true });
225
+ await fs.copyFile(src, dest);
226
+ n += 1;
227
+ }
228
+ return n;
229
+ }
230
+ /** Copy each resolved project's `.crewly/` from the archive to its target path. */
231
+ async applyProjects(temp, manifest, plan) {
232
+ let n = 0;
233
+ const planById = new Map(plan.projects.map((p) => [p.id, p]));
234
+ for (const proj of manifest.projects) {
235
+ const target = planById.get(proj.id)?.targetPath;
236
+ if (!target)
237
+ continue; // unresolved — warned in the plan
238
+ for (const f of proj.files) {
239
+ // f.path = 'projects/<id>/.crewly/<rel>' → dest = <target>/.crewly/<rel>
240
+ const rel = f.path.replace(new RegExp(`^projects/${proj.id}/`), '');
241
+ const src = path.join(temp, ...f.path.split('/'));
242
+ const dest = path.join(target, ...rel.split('/'));
243
+ await fs.mkdir(path.dirname(dest), { recursive: true });
244
+ await fs.copyFile(src, dest);
245
+ }
246
+ n += 1;
247
+ }
248
+ return n;
249
+ }
250
+ /** Atomically swap chat.db into place (temp copy → rename). */
251
+ async applyChatDb(temp, home, manifest) {
252
+ if (!manifest.chatDb.included)
253
+ return false;
254
+ const src = path.join(temp, 'chat.db');
255
+ const finalDest = path.join(home, 'chat.db');
256
+ const tmpDest = path.join(home, `chat.db.restore-${Date.now()}.tmp`);
257
+ await fs.mkdir(home, { recursive: true });
258
+ await fs.copyFile(src, tmpDest);
259
+ await fs.rename(tmpDest, finalDest);
260
+ // Drop stale WAL/SHM sidecars so SQLite reopens cleanly from the restored db.
261
+ for (const sidecar of ['chat.db-wal', 'chat.db-shm']) {
262
+ await fs.rm(path.join(home, sidecar), { force: true }).catch(() => undefined);
263
+ }
264
+ return true;
265
+ }
266
+ /** Rewrite projects.json `path` entries using the resolved plan mapping. */
267
+ async rewriteProjectsJson(home, plan) {
268
+ const file = path.join(home, 'projects.json');
269
+ const projects = await this.readJson(file, []);
270
+ if (projects.length === 0)
271
+ return;
272
+ const targetById = new Map(plan.projects.map((p) => [p.id, p.targetPath]));
273
+ let changed = false;
274
+ for (const proj of projects) {
275
+ const target = targetById.get(proj.id);
276
+ if (target && target !== proj.path) {
277
+ proj.path = target;
278
+ changed = true;
279
+ }
280
+ }
281
+ if (changed)
282
+ await fs.writeFile(file, JSON.stringify(projects, null, 2), 'utf8');
283
+ }
284
+ /** Reset cron `nextRunAt` so the scheduler recomputes from cronExpression. */
285
+ async resetCron(home) {
286
+ for (const name of CRON_FILES) {
287
+ const file = path.join(home, name);
288
+ const items = await this.readJson(file, []);
289
+ if (!Array.isArray(items) || items.length === 0)
290
+ continue;
291
+ let changed = false;
292
+ for (const item of items) {
293
+ if ('nextRunAt' in item) {
294
+ item.nextRunAt = null;
295
+ changed = true;
296
+ }
297
+ }
298
+ if (changed)
299
+ await fs.writeFile(file, JSON.stringify(items, null, 2), 'utf8');
300
+ }
301
+ }
302
+ /** Remove runtime/session state so agents start clean on the restored machine. */
303
+ async wipeRuntime(home) {
304
+ for (const name of RUNTIME_WIPE) {
305
+ await fs.rm(path.join(home, name), { recursive: true, force: true }).catch(() => undefined);
306
+ }
307
+ }
308
+ // ---- small fs helpers ----
309
+ /** Recursively copy a directory tree, skipping top-level names in `skipTop`. */
310
+ async copyTree(srcDir, destDir, skipTop) {
311
+ const entries = await fs.readdir(srcDir, { withFileTypes: true }).catch(() => []);
312
+ await fs.mkdir(destDir, { recursive: true });
313
+ for (const e of entries) {
314
+ if (skipTop.has(e.name))
315
+ continue;
316
+ const s = path.join(srcDir, e.name);
317
+ const d = path.join(destDir, e.name);
318
+ if (e.isDirectory())
319
+ await this.copyTreeRec(s, d);
320
+ else if (e.isFile())
321
+ await fs.copyFile(s, d);
322
+ }
323
+ }
324
+ /** Recursive copy without the top-level skip filter. */
325
+ async copyTreeRec(srcDir, destDir) {
326
+ await fs.mkdir(destDir, { recursive: true });
327
+ for (const e of await fs.readdir(srcDir, { withFileTypes: true })) {
328
+ const s = path.join(srcDir, e.name);
329
+ const d = path.join(destDir, e.name);
330
+ if (e.isDirectory())
331
+ await this.copyTreeRec(s, d);
332
+ else if (e.isFile())
333
+ await fs.copyFile(s, d);
334
+ }
335
+ }
336
+ async pathExists(p) {
337
+ return !!(await fs.stat(p).catch(() => null));
338
+ }
339
+ async readJson(file, fallback) {
340
+ try {
341
+ return JSON.parse(await fs.readFile(file, 'utf8'));
342
+ }
343
+ catch {
344
+ return fallback;
345
+ }
346
+ }
347
+ }
348
+ /** Stream a file through SHA-256 → hex digest. */
349
+ async function sha256File(filePath) {
350
+ return new Promise((resolve, reject) => {
351
+ const hash = createHash('sha256');
352
+ const stream = createReadStream(filePath);
353
+ stream.on('data', (c) => hash.update(c));
354
+ stream.on('error', reject);
355
+ stream.on('end', () => resolve(hash.digest('hex')));
356
+ });
357
+ }
358
+ //# sourceMappingURL=backup-restore.service.js.map