@apicircle/core 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,110 +1,110 @@
1
- API Circle Studio License
2
- Custom Source-Available License, v1.0
3
-
4
- Copyright (c) 2026 Deva Prakash ("Licensor")
5
-
6
- The source code in this repository ("the Software") is made available for
7
- the purposes of transparency, security review, contribution, and personal
8
- evaluation. This is NOT an open-source license as defined by the Open
9
- Source Initiative.
10
-
11
- 0. Definitions
12
-
13
- "Commercial Use" means any use of the Software that is intended for
14
- or directed toward commercial advantage or monetary compensation,
15
- whether direct or indirect, including without limitation:
16
-
17
- (a) using the Software to build, test, develop, deploy, or operate
18
- any product, service, application, or system that is sold,
19
- licensed, hosted, distributed, or otherwise made available to
20
- any third party for a fee or other consideration;
21
- (b) using the Software in the course of paid employment, paid
22
- consulting, paid contracting, freelance work, or any other
23
- revenue-generating activity, regardless of whether the Software
24
- itself is sold or transferred;
25
- (c) using the Software internally within a for-profit organization
26
- beyond the Evaluation Period defined below;
27
- (d) integrating the Software, or any portion of it, into any tool,
28
- pipeline, automation, or workflow that supports the commercial
29
- operations of any business;
30
- (e) using the Software to provide a hosted, managed, or embedded
31
- service of any kind to a third party, whether free or paid.
32
-
33
- "Non-Commercial Use" means any use that is not Commercial Use,
34
- including personal hobby projects, individual learning, academic
35
- research, classroom instruction, and good-faith contribution to
36
- this repository.
37
-
38
- "Evaluation Period" means a single, continuous period of up to
39
- thirty (30) days during which a for-profit organization may
40
- internally evaluate the Software at no charge. After the Evaluation
41
- Period expires, any further use of the Software by that organization
42
- constitutes Commercial Use and requires a separate commercial
43
- license from the Licensor.
44
-
45
- 1. Permitted Use
46
-
47
- You may, without charge:
48
-
49
- (a) view, read, and study the source code of the Software;
50
- (b) run the Software, in source or compiled form, for your own
51
- Non-Commercial Use, or for Commercial Use solely within the
52
- Evaluation Period;
53
- (c) submit improvements to the Software back to this repository via
54
- pull request, subject to Section 3 (Contributions).
55
-
56
- 2. Prohibited Use
57
-
58
- Without prior written permission from the Licensor, you may NOT:
59
-
60
- (a) make any Commercial Use of the Software, in whole or in part,
61
- except during the Evaluation Period as defined in Section 0;
62
- (b) redistribute the Software, in source or compiled form, whether
63
- modified or unmodified, to any third party;
64
- (c) sublicense, sell, rent, lease, or otherwise transfer the
65
- Software or any rights granted herein;
66
- (d) remove, obscure, or alter any copyright, trademark, attribution,
67
- or license notice contained in the Software;
68
- (e) use the names "API Circle", "API Circle Studio", or any related
69
- logos or trademarks, except as required for accurate attribution.
70
-
71
- 3. Contributions
72
-
73
- By submitting any contribution (including but not limited to code,
74
- documentation, or assets) to this repository, you grant the Licensor
75
- a perpetual, worldwide, irrevocable, royalty-free, sublicensable
76
- license to use, modify, distribute, and relicense your contribution
77
- under any terms, including the terms of this license or any future
78
- version thereof.
79
-
80
- 4. No Trademark License
81
-
82
- This license does not grant permission to use the trade names,
83
- trademarks, service marks, or product names of the Licensor, except
84
- as required for reasonable and customary use in describing the
85
- origin of the Software.
86
-
87
- 5. Termination
88
-
89
- The rights granted in Section 1 terminate automatically if you
90
- breach any term of this license. Upon termination, you must cease
91
- all use of the Software and destroy all copies in your possession.
92
-
93
- 6. Disclaimer of Warranty
94
-
95
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
96
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
97
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
98
- NON-INFRINGEMENT.
99
-
100
- 7. Limitation of Liability
101
-
102
- IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR
103
- OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
104
- OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE
105
- OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
106
-
107
- 8. Commercial Licensing
108
-
109
- For commercial licensing, redistribution, or any use not permitted
110
- under Section 1, contact: apicircle365@gmail.com
1
+ API Circle Studio License
2
+ Custom Source-Available License, v1.0
3
+
4
+ Copyright (c) 2026 Deva Prakash ("Licensor")
5
+
6
+ The source code in this repository ("the Software") is made available for
7
+ the purposes of transparency, security review, contribution, and personal
8
+ evaluation. This is NOT an open-source license as defined by the Open
9
+ Source Initiative.
10
+
11
+ 0. Definitions
12
+
13
+ "Commercial Use" means any use of the Software that is intended for
14
+ or directed toward commercial advantage or monetary compensation,
15
+ whether direct or indirect, including without limitation:
16
+
17
+ (a) using the Software to build, test, develop, deploy, or operate
18
+ any product, service, application, or system that is sold,
19
+ licensed, hosted, distributed, or otherwise made available to
20
+ any third party for a fee or other consideration;
21
+ (b) using the Software in the course of paid employment, paid
22
+ consulting, paid contracting, freelance work, or any other
23
+ revenue-generating activity, regardless of whether the Software
24
+ itself is sold or transferred;
25
+ (c) using the Software internally within a for-profit organization
26
+ beyond the Evaluation Period defined below;
27
+ (d) integrating the Software, or any portion of it, into any tool,
28
+ pipeline, automation, or workflow that supports the commercial
29
+ operations of any business;
30
+ (e) using the Software to provide a hosted, managed, or embedded
31
+ service of any kind to a third party, whether free or paid.
32
+
33
+ "Non-Commercial Use" means any use that is not Commercial Use,
34
+ including personal hobby projects, individual learning, academic
35
+ research, classroom instruction, and good-faith contribution to
36
+ this repository.
37
+
38
+ "Evaluation Period" means a single, continuous period of up to
39
+ thirty (30) days during which a for-profit organization may
40
+ internally evaluate the Software at no charge. After the Evaluation
41
+ Period expires, any further use of the Software by that organization
42
+ constitutes Commercial Use and requires a separate commercial
43
+ license from the Licensor.
44
+
45
+ 1. Permitted Use
46
+
47
+ You may, without charge:
48
+
49
+ (a) view, read, and study the source code of the Software;
50
+ (b) run the Software, in source or compiled form, for your own
51
+ Non-Commercial Use, or for Commercial Use solely within the
52
+ Evaluation Period;
53
+ (c) submit improvements to the Software back to this repository via
54
+ pull request, subject to Section 3 (Contributions).
55
+
56
+ 2. Prohibited Use
57
+
58
+ Without prior written permission from the Licensor, you may NOT:
59
+
60
+ (a) make any Commercial Use of the Software, in whole or in part,
61
+ except during the Evaluation Period as defined in Section 0;
62
+ (b) redistribute the Software, in source or compiled form, whether
63
+ modified or unmodified, to any third party;
64
+ (c) sublicense, sell, rent, lease, or otherwise transfer the
65
+ Software or any rights granted herein;
66
+ (d) remove, obscure, or alter any copyright, trademark, attribution,
67
+ or license notice contained in the Software;
68
+ (e) use the names "API Circle", "API Circle Studio", or any related
69
+ logos or trademarks, except as required for accurate attribution.
70
+
71
+ 3. Contributions
72
+
73
+ By submitting any contribution (including but not limited to code,
74
+ documentation, or assets) to this repository, you grant the Licensor
75
+ a perpetual, worldwide, irrevocable, royalty-free, sublicensable
76
+ license to use, modify, distribute, and relicense your contribution
77
+ under any terms, including the terms of this license or any future
78
+ version thereof.
79
+
80
+ 4. No Trademark License
81
+
82
+ This license does not grant permission to use the trade names,
83
+ trademarks, service marks, or product names of the Licensor, except
84
+ as required for reasonable and customary use in describing the
85
+ origin of the Software.
86
+
87
+ 5. Termination
88
+
89
+ The rights granted in Section 1 terminate automatically if you
90
+ breach any term of this license. Upon termination, you must cease
91
+ all use of the Software and destroy all copies in your possession.
92
+
93
+ 6. Disclaimer of Warranty
94
+
95
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
96
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
97
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
98
+ NON-INFRINGEMENT.
99
+
100
+ 7. Limitation of Liability
101
+
102
+ IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR
103
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
104
+ OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE
105
+ OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
106
+
107
+ 8. Commercial Licensing
108
+
109
+ For commercial licensing, redistribution, or any use not permitted
110
+ under Section 1, contact: apicircle365@gmail.com
package/README.md CHANGED
@@ -4,31 +4,200 @@
4
4
 
5
5
  <h1 align="center">@apicircle/core</h1>
6
6
 
7
- The engine behind [API Circle Studio](https://github.com/apicircle/studio) — request execution, environment resolution, auth signing, assertions, spec imports, and the `applyMutation` workspace mutation API.
7
+ <p align="center">
8
+ <strong>An embeddable API engine — execute requests, sign auth, import specs, and mutate workspaces from any JavaScript runtime.</strong><br />
9
+ The same engine that powers the API Circle Studio desktop app, web app, CLI, and MCP server.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@apicircle/core"><img src="https://img.shields.io/npm/v/@apicircle/core?color=cb3837&logo=npm" alt="npm version" /></a>
14
+ <img src="https://img.shields.io/badge/auth%20schemes-17-blueviolet" alt="17 auth schemes" />
15
+ <img src="https://img.shields.io/badge/imports-OpenAPI%20%C2%B7%20Postman%20%C2%B7%20Insomnia%20%C2%B7%20cURL-blue" alt="Importers" />
16
+ <img src="https://img.shields.io/badge/runtimes-Node%20%C2%B7%20Bun%20%C2%B7%20Browser-success" alt="Runtimes" />
17
+ <img src="https://img.shields.io/badge/node-%E2%89%A5%2020-brightgreen" alt="Node ≥ 20" />
18
+ </p>
19
+
20
+ ---
21
+
22
+ ## What it is
23
+
24
+ `@apicircle/core` is the headless engine behind [API Circle Studio](https://github.com/apicircle/studio).
25
+ It does **everything an API client needs to do** — except draw a UI:
26
+
27
+ - Builds and executes HTTP requests with redirect, retry, and timeout handling.
28
+ - Signs **17 different auth schemes** end-to-end, including the full OAuth2 grant set.
29
+ - Imports **OpenAPI 3.x, Swagger 2.0, Postman v2/v2.1, Insomnia v4, and cURL** into a typed workspace.
30
+ - Mutates workspace state through a single, audited choke point (`applyMutation`).
31
+ - Runs saved execution plans headlessly — perfect for CI / regression gates.
32
+ - Reads and writes workspace JSON to disk with advisory locking, in either a single-folder or multi-workspace registry layout.
33
+
34
+ If you've ever wanted to embed "the Postman engine" inside a Node script,
35
+ a CI runner, an internal portal, or your own product — this is that engine,
36
+ typed and treeshakable.
8
37
 
9
38
  ## Install
10
39
 
11
40
  ```bash
12
- npm install @apicircle/core
41
+ npm install @apicircle/core @apicircle/shared
42
+ # pnpm add @apicircle/core @apicircle/shared
13
43
  ```
14
44
 
15
- ## What's inside
45
+ Dual ESM + CJS, full `.d.ts`, no native deps. Browser-safe — all crypto and
46
+ signing primitives run on WebCrypto.
16
47
 
17
- - **Request execution** `executeRequest`, request building, redirect and retry handling.
18
- - **Auth signing** — all 17 `RequestAuth` schemes: Bearer, Basic, API key, custom header, the full OAuth2 grant set, AWS SigV4, Digest, NTLM, Hawk, and JWT.
19
- - **`applyMutation(state, patch)`** — the single mutation choke point for every workspace write, over the `WorkspacePatch` discriminated union.
20
- - **Imports** — cURL, OpenAPI / Swagger, Postman, and Insomnia parsers.
21
- - **Assertions and execution plans** — `runPlan` executes a saved plan headlessly.
22
- - **Git serialization** stable JSON serialize / three-way merge for clean workspace diffs.
48
+ ## What you can do with it
49
+
50
+ ### Execute any request, with real auth
51
+
52
+ ```ts
53
+ import { executeRequest } from '@apicircle/core';
54
+
55
+ const result = await executeRequest({
56
+ method: 'POST',
57
+ url: 'https://api.example.com/charges',
58
+ headers: [{ key: 'X-Idempotency-Key', value: 'k-001' }],
59
+ body: { kind: 'json', json: { amount: 4200, currency: 'usd' } },
60
+ auth: {
61
+ type: 'oauth2',
62
+ grant: 'client_credentials',
63
+ clientId: 'cid',
64
+ clientSecret: 'csec',
65
+ tokenUrl: 'https://auth.example.com/token',
66
+ scope: 'charges:write',
67
+ },
68
+ });
69
+
70
+ console.log(result.response.status, result.response.bodyText);
71
+ ```
72
+
73
+ Behind the scenes the engine acquires a token, caches it for its TTL,
74
+ refreshes it transparently when it expires, and signs the request. Every
75
+ other auth scheme — Bearer, Basic, API key, custom header, AWS SigV4, Digest
76
+ (with `stale=true` nonce rotation), NTLM (with the full 3-message handshake +
77
+ MIC), Hawk, JWT — works the same way: declarative config in, signed request out.
78
+
79
+ ### The 17 auth schemes, in one place
80
+
81
+ | Family | Schemes |
82
+ | ----------- | ------------------------------------------------------------------------------------------------------------ |
83
+ | Token | Bearer, API key, custom header |
84
+ | Credentials | Basic, Digest (MD5/SHA-256/SHA-512-256), NTLM v2, Hawk |
85
+ | AWS | SigV4 (header & query) |
86
+ | JWT | HS / RS / ES / PS family, custom claims |
87
+ | OAuth2 | Client Credentials, Authorization Code (+ PKCE), Password, Implicit, Device Code, Refresh, `private_key_jwt` |
88
+ | None | …because sometimes the API just lets you in |
89
+
90
+ Every implementation is verified against the original RFCs (1320, 1321, 2202,
91
+ 2617, 7616, 7636, [MS-NLMP]) plus NIST CAVS vectors and Mozilla's Hawk
92
+ fixtures. See [`docs/auth.md`](https://github.com/apicircle/studio/blob/main/docs/auth.md)
93
+ for the full matrix.
94
+
95
+ ### Import a spec, get a typed collection
96
+
97
+ ```ts
98
+ import { importOpenApi, importPostman, importInsomnia, importCurl } from '@apicircle/core';
99
+
100
+ const { requests, folders, warnings } = await importOpenApi(rawYamlOrJson, {
101
+ format: 'yaml',
102
+ });
103
+ ```
104
+
105
+ All four importers return the same typed shape, ready to merge into a
106
+ workspace with `applyMutation`.
107
+
108
+ ### Run a saved plan in CI
109
+
110
+ ```ts
111
+ import { runPlan, loadFromFile } from '@apicircle/core/workspace/file-backed';
112
+
113
+ const { synced, local } = await loadFromFile('./workspace');
114
+ const planId = synced.plans.find((p) => p.name === 'Smoke Tests')!.id;
115
+
116
+ const result = await runPlan({ synced, local }, planId, { bail: true });
117
+ process.exit(result.passed ? 0 : 1);
118
+ ```
119
+
120
+ Plans chain requests, evaluate assertions, and pass extracted variables
121
+ forward — exit code 0 on green, non-zero on red. Drop it straight into GitHub
122
+ Actions, GitLab CI, or any pipeline.
123
+
124
+ ### Mutate workspace state — one function, every entity
125
+
126
+ ```ts
127
+ import { applyMutation, generateId } from '@apicircle/core';
128
+
129
+ const next = applyMutation(state, {
130
+ kind: 'request.create',
131
+ id: generateId(),
132
+ name: 'List Users',
133
+ parentFolderId: null,
134
+ method: 'GET',
135
+ url: 'https://api.example.com/users',
136
+ });
137
+ ```
138
+
139
+ `WorkspacePatch` is a discriminated union over `request.* | folder.* |
140
+ environment.* | assertion.* | mock.* | plan.*`. Adding a new entity in your
141
+ own UI? One union variant + one switch case. The CLI, MCP server, and desktop
142
+ app all flow through this single function — it's the audit log seam.
23
143
 
24
144
  ## Entry points
25
145
 
26
146
  ```ts
147
+ // Engine API
27
148
  import { executeRequest, applyMutation, runPlan } from '@apicircle/core';
149
+
150
+ // Disk-backed single-workspace helpers (load/save/withWorkspace under an advisory lock)
28
151
  import { loadFromFile, saveToFile, withWorkspace } from '@apicircle/core/workspace/file-backed';
152
+
153
+ // Multi-workspace registry (registry.json + per-id subdirectories)
154
+ import {
155
+ loadRegistry,
156
+ saveRegistry,
157
+ loadWorkspaceById,
158
+ saveWorkspaceById,
159
+ registerWorkspace,
160
+ setActiveWorkspace,
161
+ deleteWorkspaceById,
162
+ findWorkspaceEntry,
163
+ migrateLegacyWorkspace,
164
+ workspaceDirFor,
165
+ type WorkspaceRegistry,
166
+ } from '@apicircle/core/workspace/registry';
167
+ ```
168
+
169
+ - **`/workspace/file-backed`** — one workspace, one folder. `proper-lockfile`
170
+ advisory locking, so concurrent CLI runs don't corrupt each other.
171
+ - **`/workspace/registry`** — many workspaces, one root. `registry.json` at
172
+ the top, per-id subdirectories underneath. The desktop app, CLI, and MCP
173
+ server all read this same shape, so an edit in one is visible to the others
174
+ on the next read.
175
+
176
+ ## Use cases
177
+
178
+ - Build an **internal API explorer** for your engineering team.
179
+ - Run **regression smoke tests** against staging in CI, with real OAuth2.
180
+ - Generate a **runtime mock** from an OpenAPI spec on the fly.
181
+ - Embed an **AI-powered request authoring** flow in your own product.
182
+ - Migrate a Postman library into a typed, Git-trackable workspace.
183
+ - Lint or validate workspaces in a pre-commit hook.
184
+
185
+ ## Where it fits
186
+
187
+ ```
188
+ @apicircle/shared (types + IDs + crypto + MCP catalog)
189
+ └── @apicircle/core ◀── you are here
190
+ ├── @apicircle/mcp-server (wraps core as MCP tools)
191
+ ├── @apicircle/cli (wraps core as a CLI binary)
192
+ └── @apicircle/mock-server-core (sister package — mock-server engine)
29
193
  ```
30
194
 
31
- `@apicircle/core/workspace/file-backed` provides disk-backed workspace helpers with `proper-lockfile` advisory locking — used by `@apicircle/cli` and the headless `@apicircle/mcp-server`.
195
+ ## Stability & test coverage
196
+
197
+ - **1,911 unit + integration tests** in the OAuth2 / signing / executor suites
198
+ alone (mock IdP exercises every grant, every refresh path, every CSRF guard).
199
+ - Every auth primitive is **CAVS-verified** against the original spec vectors.
200
+ - The engine is built ESM-first and ships dual CJS bundles for legacy consumers.
32
201
 
33
202
  ## License
34
203
 
@@ -0,0 +1,129 @@
1
+ // src/workspace/fileBackedWorkspace.ts
2
+ import { promises as fs } from "fs";
3
+ import * as path from "path";
4
+ import { FONT_SIZE_PERCENT_DEFAULT } from "@apicircle/shared";
5
+ import lockfile from "proper-lockfile";
6
+ var SYNCED_FILE = "workspace.synced.json";
7
+ var LOCAL_FILE = "workspace.local.json";
8
+ async function loadFromFile(dir, options = {}) {
9
+ const syncedPath = path.join(dir, SYNCED_FILE);
10
+ const localPath = path.join(dir, LOCAL_FILE);
11
+ let syncedRaw;
12
+ try {
13
+ syncedRaw = await fs.readFile(syncedPath, "utf-8");
14
+ } catch (err) {
15
+ if (options.allowMissing && isENOENT(err)) return null;
16
+ throw err;
17
+ }
18
+ const synced = JSON.parse(syncedRaw);
19
+ let local;
20
+ try {
21
+ local = JSON.parse(await fs.readFile(localPath, "utf-8"));
22
+ } catch (err) {
23
+ if (!isENOENT(err)) throw err;
24
+ local = createEmptyLocalForSynced(synced);
25
+ }
26
+ return { synced, local };
27
+ }
28
+ async function saveToFile(dir, state, options = {}) {
29
+ await fs.mkdir(dir, { recursive: true });
30
+ const syncedPath = path.join(dir, SYNCED_FILE);
31
+ const localPath = path.join(dir, LOCAL_FILE);
32
+ await ensureFile(syncedPath);
33
+ const release = await lockfile.lock(syncedPath, {
34
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },
35
+ stale: options.lockTimeoutMs ?? 3e4
36
+ });
37
+ try {
38
+ await writeJsonAtomic(syncedPath, state.synced);
39
+ await writeJsonAtomic(localPath, state.local);
40
+ } finally {
41
+ await release();
42
+ }
43
+ }
44
+ async function withWorkspace(dir, fn, options = {}) {
45
+ await fs.mkdir(dir, { recursive: true });
46
+ const syncedPath = path.join(dir, SYNCED_FILE);
47
+ const localPath = path.join(dir, LOCAL_FILE);
48
+ await ensureFile(syncedPath);
49
+ const release = await lockfile.lock(syncedPath, {
50
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },
51
+ stale: options.lockTimeoutMs ?? 3e4
52
+ });
53
+ try {
54
+ const syncedRaw = await fs.readFile(syncedPath, "utf-8");
55
+ const synced = JSON.parse(syncedRaw);
56
+ let local;
57
+ try {
58
+ local = JSON.parse(await fs.readFile(localPath, "utf-8"));
59
+ } catch (err) {
60
+ if (!isENOENT(err)) throw err;
61
+ local = createEmptyLocalForSynced(synced);
62
+ }
63
+ const out = await fn({ synced, local });
64
+ await writeJsonAtomic(syncedPath, out.next.synced);
65
+ await writeJsonAtomic(localPath, out.next.local);
66
+ return out.result;
67
+ } finally {
68
+ await release();
69
+ }
70
+ }
71
+ var WORKSPACE_FILE_MODE = 384;
72
+ async function ensureFile(filePath) {
73
+ try {
74
+ await fs.access(filePath);
75
+ } catch (err) {
76
+ if (!isENOENT(err)) throw err;
77
+ await fs.writeFile(filePath, "{}", { encoding: "utf-8", mode: WORKSPACE_FILE_MODE });
78
+ }
79
+ }
80
+ async function writeJsonAtomic(filePath, value) {
81
+ const tmp = `${filePath}.tmp`;
82
+ await fs.writeFile(tmp, JSON.stringify(value, null, 2) + "\n", {
83
+ encoding: "utf-8",
84
+ mode: WORKSPACE_FILE_MODE
85
+ });
86
+ await fs.rename(tmp, filePath);
87
+ }
88
+ function isENOENT(err) {
89
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
90
+ }
91
+ function createEmptyLocalForSynced(synced) {
92
+ return {
93
+ schemaVersion: 1,
94
+ workspaceId: synced.workspaceId,
95
+ executionPlans: {},
96
+ history: { requestRuns: [], planRuns: [] },
97
+ secretIndex: { entries: {} },
98
+ sessions: { github: { workspace: null, links: {} } },
99
+ connectedRepo: null,
100
+ workingBranch: null,
101
+ seededWorkspaceSha: null,
102
+ retiredBranch: null,
103
+ sync: {
104
+ lastPulledSnapshot: null,
105
+ lastPulledSha: null,
106
+ lastPulledAt: null,
107
+ dirtyKeys: []
108
+ },
109
+ linkedCollections: {},
110
+ globalContext: {},
111
+ mockRuntime: { active: {} },
112
+ ui: {
113
+ activeRequestId: null,
114
+ sidebarExpandedSections: [],
115
+ themeId: "studio-dark",
116
+ fontId: "system-mono",
117
+ fontSizePercent: FONT_SIZE_PERCENT_DEFAULT
118
+ },
119
+ settings: { validateOnSend: true, monacoConsumesWheel: false },
120
+ snapshots: { entries: [], maxBytes: 50 * 1024 * 1024 }
121
+ };
122
+ }
123
+
124
+ export {
125
+ loadFromFile,
126
+ saveToFile,
127
+ withWorkspace
128
+ };
129
+ //# sourceMappingURL=chunk-SGI6KGQ7.js.map
@@ -0,0 +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 } 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 } 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 globalContext: {},\n mockRuntime: { active: {} },\n ui: {\n activeRequestId: null,\n sidebarExpandedSections: [],\n themeId: 'studio-dark',\n fontId: 'system-mono',\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":[]}