@apicircle/core 1.0.6 → 1.0.8
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/dist/{chunk-PUXJFN2Z.js → chunk-L5DQT7V6.js} +3 -3
- package/dist/{chunk-PUXJFN2Z.js.map → chunk-L5DQT7V6.js.map} +1 -1
- package/dist/index.cjs +996 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +123 -3
- package/dist/index.d.ts +123 -3
- package/dist/index.js +972 -6
- package/dist/index.js.map +1 -1
- package/dist/patches-ysO3y8pG.d.cts +312 -0
- package/dist/patches-ysO3y8pG.d.ts +312 -0
- package/dist/workspace/file-backed.cjs +2 -2
- package/dist/workspace/file-backed.cjs.map +1 -1
- package/dist/workspace/file-backed.d.cts +1 -1
- package/dist/workspace/file-backed.d.ts +1 -1
- package/dist/workspace/file-backed.js +1 -1
- package/dist/workspace/registry.cjs +2 -2
- package/dist/workspace/registry.cjs.map +1 -1
- package/dist/workspace/registry.d.cts +1 -1
- package/dist/workspace/registry.d.ts +1 -1
- package/dist/workspace/registry.js +1 -1
- package/package.json +28 -2
- package/dist/patches-N7mvDpXn.d.cts +0 -85
- package/dist/patches-N7mvDpXn.d.ts +0 -85
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { RequestAuth, GlobalSchema, GlobalGraphQL, GlobalFileAsset, Folder, Request, WorkspaceSynced, WorkspaceLocal, Environment, EnvPriorityRef, SecretKeyMeta, Assertion, MockServer, ExecutionPlan, WorkspaceSnapshotTrigger } from '@apicircle/shared';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One credential-bearing field discovered inside an export envelope.
|
|
5
|
+
*
|
|
6
|
+
* `id` is a stable composite that survives ordering / re-serialization
|
|
7
|
+
* — UIs can use it as the React key and as the `include` set member.
|
|
8
|
+
*
|
|
9
|
+
* Format:
|
|
10
|
+
* - Root folder auth: `folder:<envelope.source.folderId>.<authType>.<field>`
|
|
11
|
+
* - Subfolder auth: `folder:<subfolder.id>.<authType>.<field>`
|
|
12
|
+
* - Request auth: `request:<request.id>.<authType>.<field>`
|
|
13
|
+
*/
|
|
14
|
+
interface FolderExportCredential {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Where the credential lives in the envelope. */
|
|
17
|
+
scope: 'root-folder' | 'subfolder' | 'request';
|
|
18
|
+
/** Discriminator of the auth variant that owns the field. */
|
|
19
|
+
authType: RequestAuth['type'];
|
|
20
|
+
/** Field name on the auth object (e.g. "token", "password", "clientSecret"). */
|
|
21
|
+
field: string;
|
|
22
|
+
/** Human-readable label for the UI ("Bearer · token"). */
|
|
23
|
+
label: string;
|
|
24
|
+
/**
|
|
25
|
+
* Where this credential belongs to. For requests this is the
|
|
26
|
+
* request name; for folders it's the folder name. UIs use this to
|
|
27
|
+
* group rows so the user can see which entity is leaking what.
|
|
28
|
+
*/
|
|
29
|
+
ownerName: string;
|
|
30
|
+
/** Source-workspace id of the request/folder that owns this field. */
|
|
31
|
+
ownerId: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sorted, stable list of every credential-bearing field in the envelope.
|
|
35
|
+
*
|
|
36
|
+
* Determinism: rows are ordered by `(scope-rank, ownerName, field)` so
|
|
37
|
+
* the same envelope always produces the same UI row order. Re-running
|
|
38
|
+
* the detector after the user toggles include-checkboxes returns the
|
|
39
|
+
* same list with the same ids — the UI never needs to remap state.
|
|
40
|
+
*
|
|
41
|
+
* Pure — does not mutate the envelope.
|
|
42
|
+
*/
|
|
43
|
+
declare function collectFolderExportCredentials(envelope: ApicircleFolderExportV1): FolderExportCredential[];
|
|
44
|
+
/**
|
|
45
|
+
* Return a new envelope with every credential-bearing field blanked,
|
|
46
|
+
* except for fields whose `id` appears in `includeIds`. The default
|
|
47
|
+
* (empty `includeIds`) redacts everything — that's the safe default
|
|
48
|
+
* the modal uses when the user hasn't explicitly opted any credential
|
|
49
|
+
* in.
|
|
50
|
+
*
|
|
51
|
+
* The redaction shape mirrors `redactForGit`: credential FIELDS go to
|
|
52
|
+
* `''`, identity fields (`clientId`, `username`, `tokenUrl`, …) stay so
|
|
53
|
+
* the importer still knows which IdP the request originally talked to.
|
|
54
|
+
*/
|
|
55
|
+
declare function redactFolderExportCredentials(envelope: ApicircleFolderExportV1, includeIds?: ReadonlySet<string>): ApicircleFolderExportV1;
|
|
56
|
+
|
|
57
|
+
/** Envelope discriminator. Bump the version suffix on a breaking shape change. */
|
|
58
|
+
declare const APICIRCLE_FOLDER_EXPORT_FORMAT = "apicircle.folder/v1";
|
|
59
|
+
interface ApicircleFolderExportV1 {
|
|
60
|
+
format: typeof APICIRCLE_FOLDER_EXPORT_FORMAT;
|
|
61
|
+
/** ISO timestamp of when the export was generated. */
|
|
62
|
+
exportedAt: string;
|
|
63
|
+
/** App version that produced the export (free-form string). */
|
|
64
|
+
appVersion: string;
|
|
65
|
+
/** Loose breadcrumb back to the source — never required by importers. */
|
|
66
|
+
source: {
|
|
67
|
+
workspaceId: string;
|
|
68
|
+
folderId: string;
|
|
69
|
+
folderName: string;
|
|
70
|
+
};
|
|
71
|
+
folder: {
|
|
72
|
+
/** Display name of the exported root folder. */
|
|
73
|
+
name: string;
|
|
74
|
+
/** Folder-level auth, if any was set on the source root folder. */
|
|
75
|
+
auth?: Folder['auth'];
|
|
76
|
+
/**
|
|
77
|
+
* Descendant folders (NOT including the root). `parentId` is the
|
|
78
|
+
* source workspace's id of the parent — when it equals `source.folderId`
|
|
79
|
+
* the folder lives directly under the exported root.
|
|
80
|
+
*/
|
|
81
|
+
subfolders: Folder[];
|
|
82
|
+
/**
|
|
83
|
+
* All requests inside the exported subtree. `folderId` is the source
|
|
84
|
+
* id of the immediate parent folder; the importer remaps it onto the
|
|
85
|
+
* destination workspace's freshly-minted ids.
|
|
86
|
+
*/
|
|
87
|
+
requests: Request[];
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Captured global-asset dependencies referenced by the exported
|
|
91
|
+
* requests. Schemas/GraphQL travel embedded; files travel
|
|
92
|
+
* metadata-only.
|
|
93
|
+
*/
|
|
94
|
+
dependencies: ApicircleFolderExportDependencies;
|
|
95
|
+
}
|
|
96
|
+
interface ApicircleFolderExportDependencies {
|
|
97
|
+
schemas: GlobalSchema[];
|
|
98
|
+
graphql: GlobalGraphQL[];
|
|
99
|
+
/**
|
|
100
|
+
* File-asset METADATA only. The `slotId` is preserved so the importer
|
|
101
|
+
* can correlate against an existing slot on the destination side if
|
|
102
|
+
* one happens to match; otherwise the user is prompted to re-attach.
|
|
103
|
+
*/
|
|
104
|
+
files: GlobalFileAsset[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Plain-language summary of what the export contains, surfaced inside the
|
|
108
|
+
* Export Folder modal so the user knows exactly what's leaving the
|
|
109
|
+
* workspace before they click Download.
|
|
110
|
+
*/
|
|
111
|
+
interface FolderExportReport {
|
|
112
|
+
folderName: string;
|
|
113
|
+
requestCount: number;
|
|
114
|
+
subfolderCount: number;
|
|
115
|
+
/** Total folder count INCLUDING the exported root. */
|
|
116
|
+
totalFolderCount: number;
|
|
117
|
+
dependencies: {
|
|
118
|
+
schemas: Array<{
|
|
119
|
+
id: string;
|
|
120
|
+
name: string;
|
|
121
|
+
}>;
|
|
122
|
+
graphql: Array<{
|
|
123
|
+
id: string;
|
|
124
|
+
name: string;
|
|
125
|
+
kind: GlobalGraphQL['kind'];
|
|
126
|
+
}>;
|
|
127
|
+
files: Array<{
|
|
128
|
+
id: string;
|
|
129
|
+
name: string;
|
|
130
|
+
filename: string;
|
|
131
|
+
size: number;
|
|
132
|
+
mimeType: string;
|
|
133
|
+
}>;
|
|
134
|
+
};
|
|
135
|
+
/** Convenience flag — `true` when any dependency was captured. */
|
|
136
|
+
hasDependencies: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Every credential-bearing field detected inside the envelope's auth
|
|
139
|
+
* blocks (root folder, subfolders, requests). Surfaced by the export
|
|
140
|
+
* modal so the user can opt-in per-field before the file leaves the
|
|
141
|
+
* workspace. Defaults to "redact everything" — see
|
|
142
|
+
* `redactFolderExportCredentials`.
|
|
143
|
+
*/
|
|
144
|
+
credentials: FolderExportCredential[];
|
|
145
|
+
/** Convenience flag — `true` when any credential was detected. */
|
|
146
|
+
hasCredentials: boolean;
|
|
147
|
+
}
|
|
148
|
+
interface CollectFolderExportArgs {
|
|
149
|
+
synced: WorkspaceSynced;
|
|
150
|
+
folderId: string;
|
|
151
|
+
/** Defaults to `new Date().toISOString()` — overridable for deterministic tests. */
|
|
152
|
+
now?: string;
|
|
153
|
+
/** Defaults to `'apicircle-studio'` — overridable for deterministic tests. */
|
|
154
|
+
appVersion?: string;
|
|
155
|
+
}
|
|
156
|
+
interface CollectFolderExportResult {
|
|
157
|
+
envelope: ApicircleFolderExportV1;
|
|
158
|
+
report: FolderExportReport;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Walk the subtree rooted at `folderId`, collect its requests + descendant
|
|
162
|
+
* folders, gather every referenced global-asset dependency, and assemble
|
|
163
|
+
* a self-describing export envelope.
|
|
164
|
+
*
|
|
165
|
+
* Returns `null` when `folderId` doesn't exist — UI callers should treat
|
|
166
|
+
* that as a no-op (the source folder was deleted between menu open and
|
|
167
|
+
* click).
|
|
168
|
+
*/
|
|
169
|
+
declare function collectFolderExport(args: CollectFolderExportArgs): CollectFolderExportResult | null;
|
|
170
|
+
/** JSON-stringify an envelope with stable, human-friendly formatting (2-space indent). */
|
|
171
|
+
declare function serializeFolderExport(envelope: ApicircleFolderExportV1): string;
|
|
172
|
+
/** Filename the UI uses for the downloaded file. Slugifies the folder name. */
|
|
173
|
+
declare function suggestFolderExportFilename(envelope: ApicircleFolderExportV1): string;
|
|
174
|
+
|
|
175
|
+
interface ParsedApicircleFolderExport {
|
|
176
|
+
/** The exported root folder, already assigned a fresh id. */
|
|
177
|
+
rootFolder: {
|
|
178
|
+
id: string;
|
|
179
|
+
name: string;
|
|
180
|
+
auth?: Folder['auth'];
|
|
181
|
+
};
|
|
182
|
+
/** Descendant folders with fresh ids + remapped parentIds. */
|
|
183
|
+
subfolders: Folder[];
|
|
184
|
+
/** Requests with fresh ids + remapped folderIds + remapped asset refs. */
|
|
185
|
+
requests: Request[];
|
|
186
|
+
/** Dependencies, ids freshly minted. */
|
|
187
|
+
dependencies: {
|
|
188
|
+
schemas: GlobalSchema[];
|
|
189
|
+
graphql: GlobalGraphQL[];
|
|
190
|
+
files: GlobalFileAsset[];
|
|
191
|
+
};
|
|
192
|
+
/** Source envelope's `source.folderName` — used for display copy. */
|
|
193
|
+
sourceFolderName: string;
|
|
194
|
+
/**
|
|
195
|
+
* Notes the parser surfaced about the import — e.g. a stale dependency
|
|
196
|
+
* reference that no longer existed in the source envelope. Importers
|
|
197
|
+
* forward these to the UI as soft warnings.
|
|
198
|
+
*/
|
|
199
|
+
warnings: string[];
|
|
200
|
+
}
|
|
201
|
+
/** Lightweight discriminator — `true` when `doc.format === 'apicircle.folder/v1'`. */
|
|
202
|
+
declare function isApicircleFolderExport(doc: unknown): doc is ApicircleFolderExportV1;
|
|
203
|
+
/**
|
|
204
|
+
* Parse + validate a raw JSON string. Throws with a single, user-readable
|
|
205
|
+
* message when the input is malformed; otherwise returns a parsed shape
|
|
206
|
+
* ready for the store to graft in.
|
|
207
|
+
*
|
|
208
|
+
* `idGenerator` is overridable for deterministic tests; defaults to
|
|
209
|
+
* `generateId()` from `@apicircle/shared`.
|
|
210
|
+
*/
|
|
211
|
+
declare function parseApicircleFolderExport(input: string, options?: {
|
|
212
|
+
idGenerator?: () => string;
|
|
213
|
+
}): ParsedApicircleFolderExport;
|
|
214
|
+
/**
|
|
215
|
+
* Same as `parseApicircleFolderExport` but skips the JSON.parse step —
|
|
216
|
+
* used by callers that already deserialized the document. Splitting the
|
|
217
|
+
* entry points keeps the validation logic identical.
|
|
218
|
+
*/
|
|
219
|
+
declare function parseApicircleFolderExportDoc(doc: unknown, options?: {
|
|
220
|
+
idGenerator?: () => string;
|
|
221
|
+
}): ParsedApicircleFolderExport;
|
|
222
|
+
|
|
223
|
+
type WorkspacePatch = {
|
|
224
|
+
kind: 'request.create';
|
|
225
|
+
request: Request;
|
|
226
|
+
} | {
|
|
227
|
+
kind: 'request.update';
|
|
228
|
+
id: string;
|
|
229
|
+
patch: Partial<Omit<Request, 'id' | 'createdAt'>>;
|
|
230
|
+
} | {
|
|
231
|
+
kind: 'request.delete';
|
|
232
|
+
id: string;
|
|
233
|
+
} | {
|
|
234
|
+
kind: 'folder.create';
|
|
235
|
+
folder: Folder;
|
|
236
|
+
} | {
|
|
237
|
+
kind: 'folder.delete';
|
|
238
|
+
id: string;
|
|
239
|
+
} | {
|
|
240
|
+
kind: 'folder.move';
|
|
241
|
+
id: string;
|
|
242
|
+
newParentId: string | null;
|
|
243
|
+
} | {
|
|
244
|
+
kind: 'folder.import_apicircle';
|
|
245
|
+
parsed: ParsedApicircleFolderExport;
|
|
246
|
+
parentFolderId: string | null;
|
|
247
|
+
} | {
|
|
248
|
+
kind: 'environment.upsert';
|
|
249
|
+
environment: Environment;
|
|
250
|
+
} | {
|
|
251
|
+
kind: 'environment.delete';
|
|
252
|
+
name: string;
|
|
253
|
+
} | {
|
|
254
|
+
kind: 'environment.setActive';
|
|
255
|
+
name: string | null;
|
|
256
|
+
} | {
|
|
257
|
+
kind: 'environment.setPriority';
|
|
258
|
+
order: EnvPriorityRef[];
|
|
259
|
+
} | {
|
|
260
|
+
kind: 'secretKey.upsert';
|
|
261
|
+
meta: SecretKeyMeta;
|
|
262
|
+
} | {
|
|
263
|
+
kind: 'assertion.upsert';
|
|
264
|
+
requestId: string;
|
|
265
|
+
assertion: Assertion;
|
|
266
|
+
} | {
|
|
267
|
+
kind: 'assertion.delete';
|
|
268
|
+
requestId: string;
|
|
269
|
+
assertionId: string;
|
|
270
|
+
} | {
|
|
271
|
+
kind: 'mock.upsert';
|
|
272
|
+
mock: MockServer;
|
|
273
|
+
} | {
|
|
274
|
+
kind: 'mock.delete';
|
|
275
|
+
id: string;
|
|
276
|
+
} | {
|
|
277
|
+
kind: 'plan.upsert';
|
|
278
|
+
plan: ExecutionPlan;
|
|
279
|
+
} | {
|
|
280
|
+
kind: 'plan.delete';
|
|
281
|
+
id: string;
|
|
282
|
+
} | {
|
|
283
|
+
kind: 'history.delete_run';
|
|
284
|
+
runId: string;
|
|
285
|
+
} | {
|
|
286
|
+
kind: 'history.delete_plan_run';
|
|
287
|
+
planRunId: string;
|
|
288
|
+
} | {
|
|
289
|
+
kind: 'history.purge';
|
|
290
|
+
olderThanMs: number;
|
|
291
|
+
} | {
|
|
292
|
+
kind: 'snapshot.capture';
|
|
293
|
+
trigger: WorkspaceSnapshotTrigger;
|
|
294
|
+
note?: string;
|
|
295
|
+
id?: string;
|
|
296
|
+
} | {
|
|
297
|
+
kind: 'snapshot.delete';
|
|
298
|
+
id: string;
|
|
299
|
+
} | {
|
|
300
|
+
kind: 'snapshot.restore';
|
|
301
|
+
id: string;
|
|
302
|
+
} | {
|
|
303
|
+
kind: 'snapshot.set_max_bytes';
|
|
304
|
+
maxBytes: number;
|
|
305
|
+
};
|
|
306
|
+
type WorkspacePatchKind = WorkspacePatch['kind'];
|
|
307
|
+
interface WorkspaceState {
|
|
308
|
+
synced: WorkspaceSynced;
|
|
309
|
+
local: WorkspaceLocal;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export { APICIRCLE_FOLDER_EXPORT_FORMAT as A, type CollectFolderExportArgs as C, type FolderExportCredential as F, type ParsedApicircleFolderExport as P, type WorkspaceState as W, type WorkspacePatch as a, type ApicircleFolderExportDependencies as b, type ApicircleFolderExportV1 as c, type CollectFolderExportResult as d, type FolderExportReport as e, type WorkspacePatchKind as f, collectFolderExport as g, collectFolderExportCredentials as h, isApicircleFolderExport as i, parseApicircleFolderExportDoc as j, suggestFolderExportFilename as k, parseApicircleFolderExport as p, redactFolderExportCredentials as r, serializeFolderExport as s };
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { RequestAuth, GlobalSchema, GlobalGraphQL, GlobalFileAsset, Folder, Request, WorkspaceSynced, WorkspaceLocal, Environment, EnvPriorityRef, SecretKeyMeta, Assertion, MockServer, ExecutionPlan, WorkspaceSnapshotTrigger } from '@apicircle/shared';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One credential-bearing field discovered inside an export envelope.
|
|
5
|
+
*
|
|
6
|
+
* `id` is a stable composite that survives ordering / re-serialization
|
|
7
|
+
* — UIs can use it as the React key and as the `include` set member.
|
|
8
|
+
*
|
|
9
|
+
* Format:
|
|
10
|
+
* - Root folder auth: `folder:<envelope.source.folderId>.<authType>.<field>`
|
|
11
|
+
* - Subfolder auth: `folder:<subfolder.id>.<authType>.<field>`
|
|
12
|
+
* - Request auth: `request:<request.id>.<authType>.<field>`
|
|
13
|
+
*/
|
|
14
|
+
interface FolderExportCredential {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Where the credential lives in the envelope. */
|
|
17
|
+
scope: 'root-folder' | 'subfolder' | 'request';
|
|
18
|
+
/** Discriminator of the auth variant that owns the field. */
|
|
19
|
+
authType: RequestAuth['type'];
|
|
20
|
+
/** Field name on the auth object (e.g. "token", "password", "clientSecret"). */
|
|
21
|
+
field: string;
|
|
22
|
+
/** Human-readable label for the UI ("Bearer · token"). */
|
|
23
|
+
label: string;
|
|
24
|
+
/**
|
|
25
|
+
* Where this credential belongs to. For requests this is the
|
|
26
|
+
* request name; for folders it's the folder name. UIs use this to
|
|
27
|
+
* group rows so the user can see which entity is leaking what.
|
|
28
|
+
*/
|
|
29
|
+
ownerName: string;
|
|
30
|
+
/** Source-workspace id of the request/folder that owns this field. */
|
|
31
|
+
ownerId: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sorted, stable list of every credential-bearing field in the envelope.
|
|
35
|
+
*
|
|
36
|
+
* Determinism: rows are ordered by `(scope-rank, ownerName, field)` so
|
|
37
|
+
* the same envelope always produces the same UI row order. Re-running
|
|
38
|
+
* the detector after the user toggles include-checkboxes returns the
|
|
39
|
+
* same list with the same ids — the UI never needs to remap state.
|
|
40
|
+
*
|
|
41
|
+
* Pure — does not mutate the envelope.
|
|
42
|
+
*/
|
|
43
|
+
declare function collectFolderExportCredentials(envelope: ApicircleFolderExportV1): FolderExportCredential[];
|
|
44
|
+
/**
|
|
45
|
+
* Return a new envelope with every credential-bearing field blanked,
|
|
46
|
+
* except for fields whose `id` appears in `includeIds`. The default
|
|
47
|
+
* (empty `includeIds`) redacts everything — that's the safe default
|
|
48
|
+
* the modal uses when the user hasn't explicitly opted any credential
|
|
49
|
+
* in.
|
|
50
|
+
*
|
|
51
|
+
* The redaction shape mirrors `redactForGit`: credential FIELDS go to
|
|
52
|
+
* `''`, identity fields (`clientId`, `username`, `tokenUrl`, …) stay so
|
|
53
|
+
* the importer still knows which IdP the request originally talked to.
|
|
54
|
+
*/
|
|
55
|
+
declare function redactFolderExportCredentials(envelope: ApicircleFolderExportV1, includeIds?: ReadonlySet<string>): ApicircleFolderExportV1;
|
|
56
|
+
|
|
57
|
+
/** Envelope discriminator. Bump the version suffix on a breaking shape change. */
|
|
58
|
+
declare const APICIRCLE_FOLDER_EXPORT_FORMAT = "apicircle.folder/v1";
|
|
59
|
+
interface ApicircleFolderExportV1 {
|
|
60
|
+
format: typeof APICIRCLE_FOLDER_EXPORT_FORMAT;
|
|
61
|
+
/** ISO timestamp of when the export was generated. */
|
|
62
|
+
exportedAt: string;
|
|
63
|
+
/** App version that produced the export (free-form string). */
|
|
64
|
+
appVersion: string;
|
|
65
|
+
/** Loose breadcrumb back to the source — never required by importers. */
|
|
66
|
+
source: {
|
|
67
|
+
workspaceId: string;
|
|
68
|
+
folderId: string;
|
|
69
|
+
folderName: string;
|
|
70
|
+
};
|
|
71
|
+
folder: {
|
|
72
|
+
/** Display name of the exported root folder. */
|
|
73
|
+
name: string;
|
|
74
|
+
/** Folder-level auth, if any was set on the source root folder. */
|
|
75
|
+
auth?: Folder['auth'];
|
|
76
|
+
/**
|
|
77
|
+
* Descendant folders (NOT including the root). `parentId` is the
|
|
78
|
+
* source workspace's id of the parent — when it equals `source.folderId`
|
|
79
|
+
* the folder lives directly under the exported root.
|
|
80
|
+
*/
|
|
81
|
+
subfolders: Folder[];
|
|
82
|
+
/**
|
|
83
|
+
* All requests inside the exported subtree. `folderId` is the source
|
|
84
|
+
* id of the immediate parent folder; the importer remaps it onto the
|
|
85
|
+
* destination workspace's freshly-minted ids.
|
|
86
|
+
*/
|
|
87
|
+
requests: Request[];
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Captured global-asset dependencies referenced by the exported
|
|
91
|
+
* requests. Schemas/GraphQL travel embedded; files travel
|
|
92
|
+
* metadata-only.
|
|
93
|
+
*/
|
|
94
|
+
dependencies: ApicircleFolderExportDependencies;
|
|
95
|
+
}
|
|
96
|
+
interface ApicircleFolderExportDependencies {
|
|
97
|
+
schemas: GlobalSchema[];
|
|
98
|
+
graphql: GlobalGraphQL[];
|
|
99
|
+
/**
|
|
100
|
+
* File-asset METADATA only. The `slotId` is preserved so the importer
|
|
101
|
+
* can correlate against an existing slot on the destination side if
|
|
102
|
+
* one happens to match; otherwise the user is prompted to re-attach.
|
|
103
|
+
*/
|
|
104
|
+
files: GlobalFileAsset[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Plain-language summary of what the export contains, surfaced inside the
|
|
108
|
+
* Export Folder modal so the user knows exactly what's leaving the
|
|
109
|
+
* workspace before they click Download.
|
|
110
|
+
*/
|
|
111
|
+
interface FolderExportReport {
|
|
112
|
+
folderName: string;
|
|
113
|
+
requestCount: number;
|
|
114
|
+
subfolderCount: number;
|
|
115
|
+
/** Total folder count INCLUDING the exported root. */
|
|
116
|
+
totalFolderCount: number;
|
|
117
|
+
dependencies: {
|
|
118
|
+
schemas: Array<{
|
|
119
|
+
id: string;
|
|
120
|
+
name: string;
|
|
121
|
+
}>;
|
|
122
|
+
graphql: Array<{
|
|
123
|
+
id: string;
|
|
124
|
+
name: string;
|
|
125
|
+
kind: GlobalGraphQL['kind'];
|
|
126
|
+
}>;
|
|
127
|
+
files: Array<{
|
|
128
|
+
id: string;
|
|
129
|
+
name: string;
|
|
130
|
+
filename: string;
|
|
131
|
+
size: number;
|
|
132
|
+
mimeType: string;
|
|
133
|
+
}>;
|
|
134
|
+
};
|
|
135
|
+
/** Convenience flag — `true` when any dependency was captured. */
|
|
136
|
+
hasDependencies: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Every credential-bearing field detected inside the envelope's auth
|
|
139
|
+
* blocks (root folder, subfolders, requests). Surfaced by the export
|
|
140
|
+
* modal so the user can opt-in per-field before the file leaves the
|
|
141
|
+
* workspace. Defaults to "redact everything" — see
|
|
142
|
+
* `redactFolderExportCredentials`.
|
|
143
|
+
*/
|
|
144
|
+
credentials: FolderExportCredential[];
|
|
145
|
+
/** Convenience flag — `true` when any credential was detected. */
|
|
146
|
+
hasCredentials: boolean;
|
|
147
|
+
}
|
|
148
|
+
interface CollectFolderExportArgs {
|
|
149
|
+
synced: WorkspaceSynced;
|
|
150
|
+
folderId: string;
|
|
151
|
+
/** Defaults to `new Date().toISOString()` — overridable for deterministic tests. */
|
|
152
|
+
now?: string;
|
|
153
|
+
/** Defaults to `'apicircle-studio'` — overridable for deterministic tests. */
|
|
154
|
+
appVersion?: string;
|
|
155
|
+
}
|
|
156
|
+
interface CollectFolderExportResult {
|
|
157
|
+
envelope: ApicircleFolderExportV1;
|
|
158
|
+
report: FolderExportReport;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Walk the subtree rooted at `folderId`, collect its requests + descendant
|
|
162
|
+
* folders, gather every referenced global-asset dependency, and assemble
|
|
163
|
+
* a self-describing export envelope.
|
|
164
|
+
*
|
|
165
|
+
* Returns `null` when `folderId` doesn't exist — UI callers should treat
|
|
166
|
+
* that as a no-op (the source folder was deleted between menu open and
|
|
167
|
+
* click).
|
|
168
|
+
*/
|
|
169
|
+
declare function collectFolderExport(args: CollectFolderExportArgs): CollectFolderExportResult | null;
|
|
170
|
+
/** JSON-stringify an envelope with stable, human-friendly formatting (2-space indent). */
|
|
171
|
+
declare function serializeFolderExport(envelope: ApicircleFolderExportV1): string;
|
|
172
|
+
/** Filename the UI uses for the downloaded file. Slugifies the folder name. */
|
|
173
|
+
declare function suggestFolderExportFilename(envelope: ApicircleFolderExportV1): string;
|
|
174
|
+
|
|
175
|
+
interface ParsedApicircleFolderExport {
|
|
176
|
+
/** The exported root folder, already assigned a fresh id. */
|
|
177
|
+
rootFolder: {
|
|
178
|
+
id: string;
|
|
179
|
+
name: string;
|
|
180
|
+
auth?: Folder['auth'];
|
|
181
|
+
};
|
|
182
|
+
/** Descendant folders with fresh ids + remapped parentIds. */
|
|
183
|
+
subfolders: Folder[];
|
|
184
|
+
/** Requests with fresh ids + remapped folderIds + remapped asset refs. */
|
|
185
|
+
requests: Request[];
|
|
186
|
+
/** Dependencies, ids freshly minted. */
|
|
187
|
+
dependencies: {
|
|
188
|
+
schemas: GlobalSchema[];
|
|
189
|
+
graphql: GlobalGraphQL[];
|
|
190
|
+
files: GlobalFileAsset[];
|
|
191
|
+
};
|
|
192
|
+
/** Source envelope's `source.folderName` — used for display copy. */
|
|
193
|
+
sourceFolderName: string;
|
|
194
|
+
/**
|
|
195
|
+
* Notes the parser surfaced about the import — e.g. a stale dependency
|
|
196
|
+
* reference that no longer existed in the source envelope. Importers
|
|
197
|
+
* forward these to the UI as soft warnings.
|
|
198
|
+
*/
|
|
199
|
+
warnings: string[];
|
|
200
|
+
}
|
|
201
|
+
/** Lightweight discriminator — `true` when `doc.format === 'apicircle.folder/v1'`. */
|
|
202
|
+
declare function isApicircleFolderExport(doc: unknown): doc is ApicircleFolderExportV1;
|
|
203
|
+
/**
|
|
204
|
+
* Parse + validate a raw JSON string. Throws with a single, user-readable
|
|
205
|
+
* message when the input is malformed; otherwise returns a parsed shape
|
|
206
|
+
* ready for the store to graft in.
|
|
207
|
+
*
|
|
208
|
+
* `idGenerator` is overridable for deterministic tests; defaults to
|
|
209
|
+
* `generateId()` from `@apicircle/shared`.
|
|
210
|
+
*/
|
|
211
|
+
declare function parseApicircleFolderExport(input: string, options?: {
|
|
212
|
+
idGenerator?: () => string;
|
|
213
|
+
}): ParsedApicircleFolderExport;
|
|
214
|
+
/**
|
|
215
|
+
* Same as `parseApicircleFolderExport` but skips the JSON.parse step —
|
|
216
|
+
* used by callers that already deserialized the document. Splitting the
|
|
217
|
+
* entry points keeps the validation logic identical.
|
|
218
|
+
*/
|
|
219
|
+
declare function parseApicircleFolderExportDoc(doc: unknown, options?: {
|
|
220
|
+
idGenerator?: () => string;
|
|
221
|
+
}): ParsedApicircleFolderExport;
|
|
222
|
+
|
|
223
|
+
type WorkspacePatch = {
|
|
224
|
+
kind: 'request.create';
|
|
225
|
+
request: Request;
|
|
226
|
+
} | {
|
|
227
|
+
kind: 'request.update';
|
|
228
|
+
id: string;
|
|
229
|
+
patch: Partial<Omit<Request, 'id' | 'createdAt'>>;
|
|
230
|
+
} | {
|
|
231
|
+
kind: 'request.delete';
|
|
232
|
+
id: string;
|
|
233
|
+
} | {
|
|
234
|
+
kind: 'folder.create';
|
|
235
|
+
folder: Folder;
|
|
236
|
+
} | {
|
|
237
|
+
kind: 'folder.delete';
|
|
238
|
+
id: string;
|
|
239
|
+
} | {
|
|
240
|
+
kind: 'folder.move';
|
|
241
|
+
id: string;
|
|
242
|
+
newParentId: string | null;
|
|
243
|
+
} | {
|
|
244
|
+
kind: 'folder.import_apicircle';
|
|
245
|
+
parsed: ParsedApicircleFolderExport;
|
|
246
|
+
parentFolderId: string | null;
|
|
247
|
+
} | {
|
|
248
|
+
kind: 'environment.upsert';
|
|
249
|
+
environment: Environment;
|
|
250
|
+
} | {
|
|
251
|
+
kind: 'environment.delete';
|
|
252
|
+
name: string;
|
|
253
|
+
} | {
|
|
254
|
+
kind: 'environment.setActive';
|
|
255
|
+
name: string | null;
|
|
256
|
+
} | {
|
|
257
|
+
kind: 'environment.setPriority';
|
|
258
|
+
order: EnvPriorityRef[];
|
|
259
|
+
} | {
|
|
260
|
+
kind: 'secretKey.upsert';
|
|
261
|
+
meta: SecretKeyMeta;
|
|
262
|
+
} | {
|
|
263
|
+
kind: 'assertion.upsert';
|
|
264
|
+
requestId: string;
|
|
265
|
+
assertion: Assertion;
|
|
266
|
+
} | {
|
|
267
|
+
kind: 'assertion.delete';
|
|
268
|
+
requestId: string;
|
|
269
|
+
assertionId: string;
|
|
270
|
+
} | {
|
|
271
|
+
kind: 'mock.upsert';
|
|
272
|
+
mock: MockServer;
|
|
273
|
+
} | {
|
|
274
|
+
kind: 'mock.delete';
|
|
275
|
+
id: string;
|
|
276
|
+
} | {
|
|
277
|
+
kind: 'plan.upsert';
|
|
278
|
+
plan: ExecutionPlan;
|
|
279
|
+
} | {
|
|
280
|
+
kind: 'plan.delete';
|
|
281
|
+
id: string;
|
|
282
|
+
} | {
|
|
283
|
+
kind: 'history.delete_run';
|
|
284
|
+
runId: string;
|
|
285
|
+
} | {
|
|
286
|
+
kind: 'history.delete_plan_run';
|
|
287
|
+
planRunId: string;
|
|
288
|
+
} | {
|
|
289
|
+
kind: 'history.purge';
|
|
290
|
+
olderThanMs: number;
|
|
291
|
+
} | {
|
|
292
|
+
kind: 'snapshot.capture';
|
|
293
|
+
trigger: WorkspaceSnapshotTrigger;
|
|
294
|
+
note?: string;
|
|
295
|
+
id?: string;
|
|
296
|
+
} | {
|
|
297
|
+
kind: 'snapshot.delete';
|
|
298
|
+
id: string;
|
|
299
|
+
} | {
|
|
300
|
+
kind: 'snapshot.restore';
|
|
301
|
+
id: string;
|
|
302
|
+
} | {
|
|
303
|
+
kind: 'snapshot.set_max_bytes';
|
|
304
|
+
maxBytes: number;
|
|
305
|
+
};
|
|
306
|
+
type WorkspacePatchKind = WorkspacePatch['kind'];
|
|
307
|
+
interface WorkspaceState {
|
|
308
|
+
synced: WorkspaceSynced;
|
|
309
|
+
local: WorkspaceLocal;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export { APICIRCLE_FOLDER_EXPORT_FORMAT as A, type CollectFolderExportArgs as C, type FolderExportCredential as F, type ParsedApicircleFolderExport as P, type WorkspaceState as W, type WorkspacePatch as a, type ApicircleFolderExportDependencies as b, type ApicircleFolderExportV1 as c, type CollectFolderExportResult as d, type FolderExportReport as e, type WorkspacePatchKind as f, collectFolderExport as g, collectFolderExportCredentials as h, isApicircleFolderExport as i, parseApicircleFolderExportDoc as j, suggestFolderExportFilename as k, parseApicircleFolderExport as p, redactFolderExportCredentials as r, serializeFolderExport as s };
|
|
@@ -151,8 +151,8 @@ function createEmptyLocalForSynced(synced) {
|
|
|
151
151
|
ui: {
|
|
152
152
|
activeRequestId: null,
|
|
153
153
|
sidebarExpandedSections: [],
|
|
154
|
-
themeId: "
|
|
155
|
-
fontId: "
|
|
154
|
+
themeId: "one-dark-pro",
|
|
155
|
+
fontId: "system-sans",
|
|
156
156
|
fontSizePercent: import_shared.FONT_SIZE_PERCENT_DEFAULT
|
|
157
157
|
},
|
|
158
158
|
settings: { validateOnSend: true, monacoConsumesWheel: false },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/workspace/fileBackedWorkspace.ts"],"sourcesContent":["import { promises as fs } from 'node:fs';\nimport * as path from 'node:path';\nimport { FONT_SIZE_PERCENT_DEFAULT } from '@apicircle/shared';\nimport type { WorkspaceLocal, WorkspaceSynced } from '@apicircle/shared';\nimport lockfile from 'proper-lockfile';\nimport type { WorkspaceState } from './patches';\n\n// =============================================================================\n// fileBackedWorkspace — load/save a `{ synced, local }` pair as two JSON\n// files on disk, with a `proper-lockfile` advisory lock so concurrent CLI /\n// MCP writers can't corrupt the document.\n//\n// Layout (relative to the directory passed in):\n// workspace.synced.json ← matches WorkspaceSynced exactly, push-to-git target\n// workspace.local.json ← WorkspaceLocal, host-private (CLI/MCP doesn't push)\n//\n// The lock is held on `workspace.synced.json` because that's the file the\n// editor races against. Stale locks are released after 30s.\n// =============================================================================\n\nconst SYNCED_FILE = 'workspace.synced.json';\nconst LOCAL_FILE = 'workspace.local.json';\n\nexport interface LoadFromFileOptions {\n /** When true, return `null` instead of throwing if the synced file is missing. */\n allowMissing?: boolean;\n}\n\nexport interface SaveToFileOptions {\n /** Lock timeout (ms). Defaults to 30000. */\n lockTimeoutMs?: number;\n}\n\n/**\n * Load both workspace documents from `dir`. The synced file is required;\n * the local file is optional and falls back to a minimal empty shape so a\n * CLI on a fresh machine can still operate (it just won't have history /\n * overrides until the desktop app runs once).\n */\nexport async function loadFromFile(\n dir: string,\n options: LoadFromFileOptions = {},\n): Promise<WorkspaceState | null> {\n const syncedPath = path.join(dir, SYNCED_FILE);\n const localPath = path.join(dir, LOCAL_FILE);\n\n let syncedRaw: string;\n try {\n syncedRaw = await fs.readFile(syncedPath, 'utf-8');\n } catch (err) {\n if (options.allowMissing && isENOENT(err)) return null;\n throw err;\n }\n const synced = JSON.parse(syncedRaw) as WorkspaceSynced;\n\n let local: WorkspaceLocal;\n try {\n local = JSON.parse(await fs.readFile(localPath, 'utf-8')) as WorkspaceLocal;\n local = { ...local, attachmentCache: local.attachmentCache ?? {} };\n } catch (err) {\n if (!isENOENT(err)) throw err;\n local = createEmptyLocalForSynced(synced);\n }\n\n return { synced, local };\n}\n\n/**\n * Atomically write both documents back to disk. Acquires an advisory lock\n * on the synced file for the duration of the write so a parallel CLI /\n * MCP / desktop save can't interleave.\n *\n * Both files are written via `<file>.tmp` + rename so a crash mid-write\n * never leaves a partial JSON document on disk.\n */\nexport async function saveToFile(\n dir: string,\n state: WorkspaceState,\n options: SaveToFileOptions = {},\n): Promise<void> {\n await fs.mkdir(dir, { recursive: true });\n const syncedPath = path.join(dir, SYNCED_FILE);\n const localPath = path.join(dir, LOCAL_FILE);\n\n // proper-lockfile requires the target file to exist. Touch it on first save.\n await ensureFile(syncedPath);\n\n const release = await lockfile.lock(syncedPath, {\n retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },\n stale: options.lockTimeoutMs ?? 30000,\n });\n try {\n await writeJsonAtomic(syncedPath, state.synced);\n await writeJsonAtomic(localPath, state.local);\n } finally {\n await release();\n }\n}\n\n/**\n * Run a load → mutate → save cycle under one lock so a single mutation\n * can't be clobbered by a racing reader-then-writer.\n */\nexport async function withWorkspace<T>(\n dir: string,\n fn: (state: WorkspaceState) => Promise<{ next: WorkspaceState; result?: T }>,\n options: SaveToFileOptions = {},\n): Promise<T | undefined> {\n await fs.mkdir(dir, { recursive: true });\n const syncedPath = path.join(dir, SYNCED_FILE);\n const localPath = path.join(dir, LOCAL_FILE);\n await ensureFile(syncedPath);\n\n const release = await lockfile.lock(syncedPath, {\n retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },\n stale: options.lockTimeoutMs ?? 30000,\n });\n try {\n const syncedRaw = await fs.readFile(syncedPath, 'utf-8');\n const synced = JSON.parse(syncedRaw) as WorkspaceSynced;\n let local: WorkspaceLocal;\n try {\n local = JSON.parse(await fs.readFile(localPath, 'utf-8')) as WorkspaceLocal;\n local = { ...local, attachmentCache: local.attachmentCache ?? {} };\n } catch (err) {\n if (!isENOENT(err)) throw err;\n local = createEmptyLocalForSynced(synced);\n }\n const out = await fn({ synced, local });\n await writeJsonAtomic(syncedPath, out.next.synced);\n await writeJsonAtomic(localPath, out.next.local);\n return out.result;\n } finally {\n await release();\n }\n}\n\n// ---------------------------------------------------------------------------\n// internals\n// ---------------------------------------------------------------------------\n\n// File mode for workspace JSON: owner read/write only. Default `fs.writeFile`\n// uses 0o666 minus umask (typically 0o644 — world-readable). The workspace\n// docs carry the synced state (which after redaction is mostly safe to read\n// but still includes per-workspace metadata) and the local state (which\n// holds the encrypted Secret Vault payload table, session metadata, and the\n// vault entries themselves). On multi-user POSIX hosts (CI runners,\n// classroom VMs, shared dev servers) the default would leak both. 0o600\n// keeps the file owner-only. Windows ignores POSIX modes — the inherited\n// per-user ACL under %USERPROFILE% is what protects it there.\nconst WORKSPACE_FILE_MODE = 0o600;\n\nasync function ensureFile(filePath: string): Promise<void> {\n try {\n await fs.access(filePath);\n } catch (err) {\n if (!isENOENT(err)) throw err;\n await fs.writeFile(filePath, '{}', { encoding: 'utf-8', mode: WORKSPACE_FILE_MODE });\n }\n}\n\nasync function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const tmp = `${filePath}.tmp`;\n await fs.writeFile(tmp, JSON.stringify(value, null, 2) + '\\n', {\n encoding: 'utf-8',\n mode: WORKSPACE_FILE_MODE,\n });\n await fs.rename(tmp, filePath);\n}\n\nfunction isENOENT(err: unknown): boolean {\n return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT';\n}\n\nfunction createEmptyLocalForSynced(synced: WorkspaceSynced): WorkspaceLocal {\n return {\n schemaVersion: 1,\n workspaceId: synced.workspaceId,\n executionPlans: {},\n history: { requestRuns: [], planRuns: [] },\n secretIndex: { entries: {} },\n sessions: { github: { workspace: null, links: {} } },\n connectedRepo: null,\n workingBranch: null,\n seededWorkspaceSha: null,\n retiredBranch: null,\n sync: {\n lastPulledSnapshot: null,\n lastPulledSha: null,\n lastPulledAt: null,\n dirtyKeys: [],\n },\n linkedCollections: {},\n attachmentCache: {},\n globalContext: {},\n mockRuntime: { active: {} },\n ui: {\n activeRequestId: null,\n sidebarExpandedSections: [],\n themeId: 'command-center',\n fontId: 'cascadia-code',\n fontSizePercent: FONT_SIZE_PERCENT_DEFAULT,\n },\n settings: { validateOnSend: true, monacoConsumesWheel: false },\n snapshots: { entries: [], maxBytes: 50 * 1024 * 1024 },\n };\n}\n"],"mappings":"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","names":["fs","lockfile"]}
|
|
1
|
+
{"version":3,"sources":["../../src/workspace/fileBackedWorkspace.ts"],"sourcesContent":["import { promises as fs } from 'node:fs';\nimport * as path from 'node:path';\nimport { FONT_SIZE_PERCENT_DEFAULT } from '@apicircle/shared';\nimport type { WorkspaceLocal, WorkspaceSynced } from '@apicircle/shared';\nimport lockfile from 'proper-lockfile';\nimport type { WorkspaceState } from './patches';\n\n// =============================================================================\n// fileBackedWorkspace — load/save a `{ synced, local }` pair as two JSON\n// files on disk, with a `proper-lockfile` advisory lock so concurrent CLI /\n// MCP writers can't corrupt the document.\n//\n// Layout (relative to the directory passed in):\n// workspace.synced.json ← matches WorkspaceSynced exactly, push-to-git target\n// workspace.local.json ← WorkspaceLocal, host-private (CLI/MCP doesn't push)\n//\n// The lock is held on `workspace.synced.json` because that's the file the\n// editor races against. Stale locks are released after 30s.\n// =============================================================================\n\nconst SYNCED_FILE = 'workspace.synced.json';\nconst LOCAL_FILE = 'workspace.local.json';\n\nexport interface LoadFromFileOptions {\n /** When true, return `null` instead of throwing if the synced file is missing. */\n allowMissing?: boolean;\n}\n\nexport interface SaveToFileOptions {\n /** Lock timeout (ms). Defaults to 30000. */\n lockTimeoutMs?: number;\n}\n\n/**\n * Load both workspace documents from `dir`. The synced file is required;\n * the local file is optional and falls back to a minimal empty shape so a\n * CLI on a fresh machine can still operate (it just won't have history /\n * overrides until the desktop app runs once).\n */\nexport async function loadFromFile(\n dir: string,\n options: LoadFromFileOptions = {},\n): Promise<WorkspaceState | null> {\n const syncedPath = path.join(dir, SYNCED_FILE);\n const localPath = path.join(dir, LOCAL_FILE);\n\n let syncedRaw: string;\n try {\n syncedRaw = await fs.readFile(syncedPath, 'utf-8');\n } catch (err) {\n if (options.allowMissing && isENOENT(err)) return null;\n throw err;\n }\n const synced = JSON.parse(syncedRaw) as WorkspaceSynced;\n\n let local: WorkspaceLocal;\n try {\n local = JSON.parse(await fs.readFile(localPath, 'utf-8')) as WorkspaceLocal;\n local = { ...local, attachmentCache: local.attachmentCache ?? {} };\n } catch (err) {\n if (!isENOENT(err)) throw err;\n local = createEmptyLocalForSynced(synced);\n }\n\n return { synced, local };\n}\n\n/**\n * Atomically write both documents back to disk. Acquires an advisory lock\n * on the synced file for the duration of the write so a parallel CLI /\n * MCP / desktop save can't interleave.\n *\n * Both files are written via `<file>.tmp` + rename so a crash mid-write\n * never leaves a partial JSON document on disk.\n */\nexport async function saveToFile(\n dir: string,\n state: WorkspaceState,\n options: SaveToFileOptions = {},\n): Promise<void> {\n await fs.mkdir(dir, { recursive: true });\n const syncedPath = path.join(dir, SYNCED_FILE);\n const localPath = path.join(dir, LOCAL_FILE);\n\n // proper-lockfile requires the target file to exist. Touch it on first save.\n await ensureFile(syncedPath);\n\n const release = await lockfile.lock(syncedPath, {\n retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },\n stale: options.lockTimeoutMs ?? 30000,\n });\n try {\n await writeJsonAtomic(syncedPath, state.synced);\n await writeJsonAtomic(localPath, state.local);\n } finally {\n await release();\n }\n}\n\n/**\n * Run a load → mutate → save cycle under one lock so a single mutation\n * can't be clobbered by a racing reader-then-writer.\n */\nexport async function withWorkspace<T>(\n dir: string,\n fn: (state: WorkspaceState) => Promise<{ next: WorkspaceState; result?: T }>,\n options: SaveToFileOptions = {},\n): Promise<T | undefined> {\n await fs.mkdir(dir, { recursive: true });\n const syncedPath = path.join(dir, SYNCED_FILE);\n const localPath = path.join(dir, LOCAL_FILE);\n await ensureFile(syncedPath);\n\n const release = await lockfile.lock(syncedPath, {\n retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },\n stale: options.lockTimeoutMs ?? 30000,\n });\n try {\n const syncedRaw = await fs.readFile(syncedPath, 'utf-8');\n const synced = JSON.parse(syncedRaw) as WorkspaceSynced;\n let local: WorkspaceLocal;\n try {\n local = JSON.parse(await fs.readFile(localPath, 'utf-8')) as WorkspaceLocal;\n local = { ...local, attachmentCache: local.attachmentCache ?? {} };\n } catch (err) {\n if (!isENOENT(err)) throw err;\n local = createEmptyLocalForSynced(synced);\n }\n const out = await fn({ synced, local });\n await writeJsonAtomic(syncedPath, out.next.synced);\n await writeJsonAtomic(localPath, out.next.local);\n return out.result;\n } finally {\n await release();\n }\n}\n\n// ---------------------------------------------------------------------------\n// internals\n// ---------------------------------------------------------------------------\n\n// File mode for workspace JSON: owner read/write only. Default `fs.writeFile`\n// uses 0o666 minus umask (typically 0o644 — world-readable). The workspace\n// docs carry the synced state (which after redaction is mostly safe to read\n// but still includes per-workspace metadata) and the local state (which\n// holds the encrypted Secret Vault payload table, session metadata, and the\n// vault entries themselves). On multi-user POSIX hosts (CI runners,\n// classroom VMs, shared dev servers) the default would leak both. 0o600\n// keeps the file owner-only. Windows ignores POSIX modes — the inherited\n// per-user ACL under %USERPROFILE% is what protects it there.\nconst WORKSPACE_FILE_MODE = 0o600;\n\nasync function ensureFile(filePath: string): Promise<void> {\n try {\n await fs.access(filePath);\n } catch (err) {\n if (!isENOENT(err)) throw err;\n await fs.writeFile(filePath, '{}', { encoding: 'utf-8', mode: WORKSPACE_FILE_MODE });\n }\n}\n\nasync function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {\n const tmp = `${filePath}.tmp`;\n await fs.writeFile(tmp, JSON.stringify(value, null, 2) + '\\n', {\n encoding: 'utf-8',\n mode: WORKSPACE_FILE_MODE,\n });\n await fs.rename(tmp, filePath);\n}\n\nfunction isENOENT(err: unknown): boolean {\n return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT';\n}\n\nfunction createEmptyLocalForSynced(synced: WorkspaceSynced): WorkspaceLocal {\n return {\n schemaVersion: 1,\n workspaceId: synced.workspaceId,\n executionPlans: {},\n history: { requestRuns: [], planRuns: [] },\n secretIndex: { entries: {} },\n sessions: { github: { workspace: null, links: {} } },\n connectedRepo: null,\n workingBranch: null,\n seededWorkspaceSha: null,\n retiredBranch: null,\n sync: {\n lastPulledSnapshot: null,\n lastPulledSha: null,\n lastPulledAt: null,\n dirtyKeys: [],\n },\n linkedCollections: {},\n attachmentCache: {},\n globalContext: {},\n mockRuntime: { active: {} },\n ui: {\n activeRequestId: null,\n sidebarExpandedSections: [],\n themeId: 'one-dark-pro',\n fontId: 'system-sans',\n fontSizePercent: FONT_SIZE_PERCENT_DEFAULT,\n },\n settings: { validateOnSend: true, monacoConsumesWheel: false },\n snapshots: { entries: [], maxBytes: 50 * 1024 * 1024 },\n };\n}\n"],"mappings":"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","names":["fs","lockfile"]}
|