reffy-cli 1.4.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -58
- package/dist/cli.js +547 -172
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.js +59 -6
- package/dist/remote.d.ts +150 -41
- package/dist/remote.js +356 -99
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +1 -1
- package/dist/types.d.ts +13 -4
- package/dist/workspace.js +6 -4
- package/package.json +1 -2
package/dist/remote.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { resolveRefsDir } from "./refs-paths.js";
|
|
4
|
-
export const REMOTE_CONFIG_VERSION =
|
|
4
|
+
export const REMOTE_CONFIG_VERSION = 4;
|
|
5
5
|
export const REMOTE_PROVIDER = "paseo";
|
|
6
|
+
export const REMOTE_BACKEND_ACTOR_TYPE = "reffyRemoteBackend";
|
|
7
|
+
export const REMOTE_BACKEND_VERSION = "v2";
|
|
8
|
+
export const REMOTE_MANAGER_ACTOR_TYPE = "reffyWorkspaceManager";
|
|
9
|
+
export const REMOTE_MANAGER_VERSION = "v1";
|
|
6
10
|
const REMOTE_STATE_DIR = path.join("state");
|
|
7
11
|
const REMOTE_CONFIG_FILE = "remote.json";
|
|
8
12
|
function isObject(value) {
|
|
@@ -14,9 +18,54 @@ function isNonEmptyString(value) {
|
|
|
14
18
|
function normalizeSeparators(value) {
|
|
15
19
|
return value.split(path.sep).join("/");
|
|
16
20
|
}
|
|
21
|
+
function readActorIdentity(value, label, configPath) {
|
|
22
|
+
if (!isObject(value)) {
|
|
23
|
+
throw new Error(`Remote config at ${configPath} must define ${label} as an object with pod_name and actor_id`);
|
|
24
|
+
}
|
|
25
|
+
if (!isNonEmptyString(value.pod_name)) {
|
|
26
|
+
throw new Error(`Remote config at ${configPath} must define ${label}.pod_name`);
|
|
27
|
+
}
|
|
28
|
+
if (!isNonEmptyString(value.actor_id)) {
|
|
29
|
+
throw new Error(`Remote config at ${configPath} must define ${label}.actor_id`);
|
|
30
|
+
}
|
|
31
|
+
return { pod_name: value.pod_name.trim(), actor_id: value.actor_id.trim() };
|
|
32
|
+
}
|
|
17
33
|
export function resolveRemoteConfigPath(repoRoot) {
|
|
18
34
|
return path.join(resolveRefsDir(repoRoot), REMOTE_STATE_DIR, REMOTE_CONFIG_FILE);
|
|
19
35
|
}
|
|
36
|
+
export function resolveSelectedWorkspaceId(identity, override) {
|
|
37
|
+
const workspaceIds = (identity.workspace_ids ?? [])
|
|
38
|
+
.map((entry) => entry.trim())
|
|
39
|
+
.filter((entry) => entry.length > 0);
|
|
40
|
+
if (override !== undefined) {
|
|
41
|
+
const trimmed = override.trim();
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
throw new Error("--workspace-id requires a non-empty value");
|
|
44
|
+
}
|
|
45
|
+
if (workspaceIds.length > 0 && !workspaceIds.includes(trimmed)) {
|
|
46
|
+
throw new Error(`Selected workspace id "${trimmed}" is not a member of manifest.workspace_ids: ${workspaceIds.join(", ") || "(none)"}`);
|
|
47
|
+
}
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
if (workspaceIds.length === 0) {
|
|
51
|
+
throw new Error("Remote sync requires .reffy/manifest.json to define workspace_ids. Run `reffy init` or add at least one workspace id.");
|
|
52
|
+
}
|
|
53
|
+
if (workspaceIds.length === 1) {
|
|
54
|
+
return workspaceIds[0];
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Manifest has multiple workspace_ids (${workspaceIds.join(", ")}); pass --workspace-id to select a target projection.`);
|
|
57
|
+
}
|
|
58
|
+
export function requireWorkspaceIdentity(identity, selectedWorkspaceId) {
|
|
59
|
+
const project_id = identity.project_id?.trim();
|
|
60
|
+
if (!project_id) {
|
|
61
|
+
throw new Error("Remote sync requires .reffy/manifest.json to define project_id");
|
|
62
|
+
}
|
|
63
|
+
const workspace_id = selectedWorkspaceId.trim();
|
|
64
|
+
if (!workspace_id) {
|
|
65
|
+
throw new Error("Remote sync requires a selected workspace_id");
|
|
66
|
+
}
|
|
67
|
+
return { project_id, workspace_id };
|
|
68
|
+
}
|
|
20
69
|
export async function readRemoteConfig(repoRoot) {
|
|
21
70
|
const configPath = resolveRemoteConfigPath(repoRoot);
|
|
22
71
|
const raw = await fs.readFile(configPath, "utf8").catch(() => null);
|
|
@@ -32,28 +81,41 @@ export async function readRemoteConfig(repoRoot) {
|
|
|
32
81
|
if (!isObject(parsed)) {
|
|
33
82
|
throw new Error(`Remote config at ${configPath} must be an object`);
|
|
34
83
|
}
|
|
35
|
-
if (parsed.version !== REMOTE_CONFIG_VERSION) {
|
|
36
|
-
throw new Error(`Remote config at ${configPath} must use version ${String(REMOTE_CONFIG_VERSION)}`);
|
|
37
|
-
}
|
|
38
84
|
if (parsed.provider !== REMOTE_PROVIDER) {
|
|
39
85
|
throw new Error(`Remote config at ${configPath} must use provider "${REMOTE_PROVIDER}"`);
|
|
40
86
|
}
|
|
41
|
-
if (
|
|
42
|
-
throw new Error(`Remote config at ${configPath}
|
|
87
|
+
if (parsed.version === 1) {
|
|
88
|
+
throw new Error(`Remote config at ${configPath} uses the legacy v1 single-target shape. Run \`reffy remote init\` against the workspace manager actor (reffyWorkspaceManager.v1) to reinitialize, then repush with \`reffy remote push\`.`);
|
|
89
|
+
}
|
|
90
|
+
if (parsed.version === 2) {
|
|
91
|
+
throw new Error(`Remote config at ${configPath} uses the legacy v2 combined-backend shape. The Paseo backend now splits into reffyWorkspaceManager.v1 (control plane) and reffyRemoteBackend.v2 (per-workspace storage). Run \`reffy remote init --manager-pod <pod> --manager-actor <actor>\` (or \`--provision\`) to reinitialize, then repush with \`reffy remote push\`.`);
|
|
92
|
+
}
|
|
93
|
+
if (parsed.version === 3) {
|
|
94
|
+
throw new Error(`Remote config at ${configPath} uses the legacy v3 shape that persisted the Paseo endpoint and pre-dates bearer-token auth. Set PASEO_ENDPOINT and PASEO_TOKEN in your .env, then run \`reffy remote init\` (with \`--provision\` for fresh setups) to reinitialize, and repush with \`reffy remote push\`.`);
|
|
95
|
+
}
|
|
96
|
+
if (parsed.version !== REMOTE_CONFIG_VERSION) {
|
|
97
|
+
throw new Error(`Remote config at ${configPath} must use version ${String(REMOTE_CONFIG_VERSION)}`);
|
|
43
98
|
}
|
|
44
|
-
|
|
45
|
-
|
|
99
|
+
const manager = readActorIdentity(parsed.manager, "manager", configPath);
|
|
100
|
+
if (!isObject(parsed.targets)) {
|
|
101
|
+
throw new Error(`Remote config at ${configPath} must define targets as an object keyed by workspace_id`);
|
|
46
102
|
}
|
|
47
|
-
|
|
48
|
-
|
|
103
|
+
const targets = {};
|
|
104
|
+
for (const [workspaceId, entry] of Object.entries(parsed.targets)) {
|
|
105
|
+
if (!isObject(entry)) {
|
|
106
|
+
throw new Error(`Remote config at ${configPath} has invalid target for workspace_id "${workspaceId}"`);
|
|
107
|
+
}
|
|
108
|
+
const workspace_backend = readActorIdentity(entry.workspace_backend, `targets.${workspaceId}.workspace_backend`, configPath);
|
|
109
|
+
targets[workspaceId] = {
|
|
110
|
+
workspace_backend,
|
|
111
|
+
last_imported_at: isNonEmptyString(entry.last_imported_at) ? entry.last_imported_at.trim() : undefined,
|
|
112
|
+
};
|
|
49
113
|
}
|
|
50
114
|
return {
|
|
51
115
|
version: REMOTE_CONFIG_VERSION,
|
|
52
116
|
provider: REMOTE_PROVIDER,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
actor_id: parsed.actor_id.trim(),
|
|
56
|
-
last_imported_at: isNonEmptyString(parsed.last_imported_at) ? parsed.last_imported_at.trim() : undefined,
|
|
117
|
+
manager,
|
|
118
|
+
targets,
|
|
57
119
|
};
|
|
58
120
|
}
|
|
59
121
|
export async function writeRemoteConfig(repoRoot, config) {
|
|
@@ -62,48 +124,96 @@ export async function writeRemoteConfig(repoRoot, config) {
|
|
|
62
124
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
63
125
|
return configPath;
|
|
64
126
|
}
|
|
65
|
-
export async function updateRemoteConfigMetadata(repoRoot, patch) {
|
|
127
|
+
export async function updateRemoteConfigMetadata(repoRoot, workspaceId, patch) {
|
|
66
128
|
const existing = await readRemoteConfig(repoRoot);
|
|
67
129
|
if (!existing)
|
|
68
130
|
return null;
|
|
131
|
+
const target = existing.targets[workspaceId];
|
|
132
|
+
if (!target)
|
|
133
|
+
return null;
|
|
69
134
|
const next = {
|
|
70
135
|
...existing,
|
|
71
|
-
|
|
136
|
+
targets: {
|
|
137
|
+
...existing.targets,
|
|
138
|
+
[workspaceId]: { ...target, ...patch },
|
|
139
|
+
},
|
|
72
140
|
};
|
|
73
141
|
await writeRemoteConfig(repoRoot, next);
|
|
74
142
|
return next;
|
|
75
143
|
}
|
|
76
|
-
export function
|
|
77
|
-
|
|
78
|
-
|
|
144
|
+
export async function removeWorkspaceTarget(repoRoot, existing, workspaceId) {
|
|
145
|
+
const existed = Object.prototype.hasOwnProperty.call(existing.targets, workspaceId);
|
|
146
|
+
if (!existed) {
|
|
147
|
+
return { config: existing, existed: false };
|
|
79
148
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
149
|
+
const nextTargets = { ...existing.targets };
|
|
150
|
+
delete nextTargets[workspaceId];
|
|
151
|
+
const next = { ...existing, targets: nextTargets };
|
|
152
|
+
await writeRemoteConfig(repoRoot, next);
|
|
153
|
+
return { config: next, existed: true };
|
|
154
|
+
}
|
|
155
|
+
export async function upsertWorkspaceTarget(repoRoot, existing, workspaceId, workspaceBackend) {
|
|
156
|
+
const priorTarget = existing.targets[workspaceId];
|
|
157
|
+
const next = {
|
|
158
|
+
...existing,
|
|
159
|
+
targets: {
|
|
160
|
+
...existing.targets,
|
|
161
|
+
[workspaceId]: {
|
|
162
|
+
workspace_backend: workspaceBackend,
|
|
163
|
+
last_imported_at: priorTarget?.last_imported_at,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
await writeRemoteConfig(repoRoot, next);
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
export function resolveRemoteTarget(stored, workspaceId, endpoint) {
|
|
171
|
+
if (!stored)
|
|
172
|
+
return null;
|
|
173
|
+
const target = stored.targets[workspaceId];
|
|
174
|
+
if (!target)
|
|
84
175
|
return null;
|
|
85
|
-
}
|
|
86
176
|
return {
|
|
87
|
-
version: REMOTE_CONFIG_VERSION,
|
|
88
|
-
provider: REMOTE_PROVIDER,
|
|
89
177
|
endpoint,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
last_imported_at:
|
|
178
|
+
manager: stored.manager,
|
|
179
|
+
workspace_backend: target.workspace_backend,
|
|
180
|
+
last_imported_at: target.last_imported_at,
|
|
93
181
|
};
|
|
94
182
|
}
|
|
95
|
-
export function describeRemoteLinkage(
|
|
96
|
-
return
|
|
183
|
+
export function describeRemoteLinkage(linkage) {
|
|
184
|
+
return [
|
|
185
|
+
`endpoint=${linkage.endpoint}`,
|
|
186
|
+
`manager=${linkage.manager.pod_name}/${linkage.manager.actor_id}`,
|
|
187
|
+
`workspace_backend=${linkage.workspace_backend.pod_name}/${linkage.workspace_backend.actor_id}`,
|
|
188
|
+
`workspace=${linkage.workspace_id}`,
|
|
189
|
+
].join(" ");
|
|
190
|
+
}
|
|
191
|
+
export class RemoteHttpError extends Error {
|
|
192
|
+
status;
|
|
193
|
+
body;
|
|
194
|
+
url;
|
|
195
|
+
method;
|
|
196
|
+
constructor(status, statusText, body, url, method) {
|
|
197
|
+
super(`${String(status)} ${statusText} - ${body}`);
|
|
198
|
+
this.status = status;
|
|
199
|
+
this.body = body;
|
|
200
|
+
this.url = url;
|
|
201
|
+
this.method = method;
|
|
202
|
+
}
|
|
97
203
|
}
|
|
98
204
|
async function http(url, init = {}) {
|
|
99
|
-
const headers
|
|
100
|
-
|
|
205
|
+
const { token, headers: rawHeaders, ...rest } = init;
|
|
206
|
+
const headers = new Headers(rawHeaders ?? {});
|
|
207
|
+
if (!headers.has("Content-Type") && rest.body) {
|
|
101
208
|
headers.set("Content-Type", "application/json");
|
|
102
209
|
}
|
|
103
|
-
|
|
210
|
+
if (token && !headers.has("Authorization")) {
|
|
211
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
212
|
+
}
|
|
213
|
+
const response = await fetch(url, { ...rest, headers });
|
|
104
214
|
if (!response.ok) {
|
|
105
215
|
const text = await response.text().catch(() => "");
|
|
106
|
-
throw new
|
|
216
|
+
throw new RemoteHttpError(response.status, response.statusText, text, url, rest.method ?? "GET");
|
|
107
217
|
}
|
|
108
218
|
return response;
|
|
109
219
|
}
|
|
@@ -111,126 +221,273 @@ async function httpJson(url, init = {}) {
|
|
|
111
221
|
const response = await http(url, init);
|
|
112
222
|
return (await response.json());
|
|
113
223
|
}
|
|
114
|
-
function actorBase(
|
|
115
|
-
return `${
|
|
224
|
+
function actorBase(endpoint, identity) {
|
|
225
|
+
return `${endpoint}/pods/${identity.pod_name}/actors/${identity.actor_id}`;
|
|
116
226
|
}
|
|
117
|
-
export class
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
227
|
+
export class PaseoManagerClient {
|
|
228
|
+
endpoint;
|
|
229
|
+
identity;
|
|
230
|
+
token;
|
|
231
|
+
constructor(endpoint, identity, token) {
|
|
232
|
+
this.endpoint = endpoint;
|
|
233
|
+
this.identity = identity;
|
|
234
|
+
this.token = token;
|
|
235
|
+
}
|
|
236
|
+
base() {
|
|
237
|
+
return actorBase(this.endpoint, this.identity);
|
|
238
|
+
}
|
|
239
|
+
requireToken() {
|
|
240
|
+
if (!this.token) {
|
|
241
|
+
throw new Error("Manager request requires PASEO_TOKEN to be set in the environment. Add it to your .env (or export it) and retry.");
|
|
242
|
+
}
|
|
243
|
+
return this.token;
|
|
121
244
|
}
|
|
122
245
|
async createPod() {
|
|
123
|
-
const result = await httpJson(`${this.
|
|
246
|
+
const result = await httpJson(`${this.endpoint}/pods`, { method: "POST" });
|
|
124
247
|
if (!isNonEmptyString(result.podName)) {
|
|
125
248
|
throw new Error("Paseo create pod response missing podName");
|
|
126
249
|
}
|
|
127
250
|
return result.podName.trim();
|
|
128
251
|
}
|
|
129
|
-
async
|
|
130
|
-
const result = await httpJson(`${this.
|
|
252
|
+
async createManagerActor(podName) {
|
|
253
|
+
const result = await httpJson(`${this.endpoint}/pods/${podName}/actors`, {
|
|
131
254
|
method: "POST",
|
|
132
255
|
body: JSON.stringify({
|
|
133
256
|
config: {
|
|
134
|
-
actorType:
|
|
135
|
-
version:
|
|
257
|
+
actorType: REMOTE_MANAGER_ACTOR_TYPE,
|
|
258
|
+
version: REMOTE_MANAGER_VERSION,
|
|
136
259
|
schema: { type: "object" },
|
|
137
|
-
params: {
|
|
138
|
-
project_id: identity.project_id,
|
|
139
|
-
workspace_name: identity.workspace_name,
|
|
140
|
-
default_lock_ttl_seconds: 120,
|
|
141
|
-
max_snapshot_documents: 1000,
|
|
142
|
-
},
|
|
260
|
+
params: {},
|
|
143
261
|
},
|
|
144
262
|
}),
|
|
145
263
|
});
|
|
146
264
|
if (!isNonEmptyString(result.actorId)) {
|
|
147
|
-
throw new Error("Paseo create actor response missing actorId");
|
|
265
|
+
throw new Error("Paseo create manager actor response missing actorId");
|
|
148
266
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return
|
|
267
|
+
if (!isNonEmptyString(result.managerAuthToken)) {
|
|
268
|
+
throw new Error("Paseo create manager actor response missing managerAuthToken. The backend may not yet expose bearer-token issuance for reffyWorkspaceManager.v1.");
|
|
269
|
+
}
|
|
270
|
+
return { actorId: result.actorId.trim(), managerAuthToken: result.managerAuthToken.trim() };
|
|
153
271
|
}
|
|
154
|
-
async
|
|
155
|
-
|
|
272
|
+
async createWorkspace(workspaceId, metadata) {
|
|
273
|
+
const result = await httpJson(`${this.base()}/workspaces`, {
|
|
156
274
|
method: "POST",
|
|
275
|
+
token: this.requireToken(),
|
|
157
276
|
body: JSON.stringify({
|
|
158
|
-
|
|
159
|
-
|
|
277
|
+
workspace_id: workspaceId,
|
|
278
|
+
metadata: metadata ?? {},
|
|
279
|
+
controller: { client: "reffy-cli" },
|
|
160
280
|
}),
|
|
161
281
|
});
|
|
282
|
+
return extractWorkspaceBackendIdentity(result, workspaceId, "createWorkspace");
|
|
162
283
|
}
|
|
163
|
-
async
|
|
164
|
-
const
|
|
165
|
-
return
|
|
284
|
+
async getWorkspace(workspaceId) {
|
|
285
|
+
const result = await httpJson(`${this.base()}/workspaces/${encodeURIComponent(workspaceId)}`, { token: this.requireToken() });
|
|
286
|
+
return extractWorkspaceBackendIdentity(result, workspaceId, "getWorkspace");
|
|
166
287
|
}
|
|
167
|
-
async
|
|
168
|
-
|
|
288
|
+
async registerProject(workspaceId, projectId) {
|
|
289
|
+
try {
|
|
290
|
+
await http(`${this.base()}/workspaces/${encodeURIComponent(workspaceId)}/projects/${encodeURIComponent(projectId)}`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
token: this.requireToken(),
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
owner: { source: "reffy-cli" },
|
|
295
|
+
metadata: { source: "reffy-cli" },
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
return { alreadyRegistered: false };
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error instanceof RemoteHttpError && error.status === 409) {
|
|
302
|
+
return { alreadyRegistered: true };
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async listProjects(workspaceId) {
|
|
308
|
+
return await httpJson(`${this.base()}/workspaces/${encodeURIComponent(workspaceId)}/projects`, { token: this.requireToken() });
|
|
309
|
+
}
|
|
310
|
+
async getProject(workspaceId, projectId) {
|
|
311
|
+
return await httpJson(`${this.base()}/workspaces/${encodeURIComponent(workspaceId)}/projects/${encodeURIComponent(projectId)}`, { token: this.requireToken() });
|
|
312
|
+
}
|
|
313
|
+
async deleteWorkspace(workspaceId) {
|
|
314
|
+
try {
|
|
315
|
+
await http(`${this.base()}/workspaces/${encodeURIComponent(workspaceId)}`, {
|
|
316
|
+
method: "DELETE",
|
|
317
|
+
token: this.requireToken(),
|
|
318
|
+
});
|
|
319
|
+
return { alreadyAbsent: false };
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (error instanceof RemoteHttpError && error.status === 404) {
|
|
323
|
+
return { alreadyAbsent: true };
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
169
327
|
}
|
|
170
328
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
329
|
+
function extractWorkspaceBackendIdentity(response, workspaceId, context) {
|
|
330
|
+
const workspace = response.workspace;
|
|
331
|
+
if (!isObject(workspace)) {
|
|
332
|
+
throw new Error(`Manager ${context} response missing workspace envelope for "${workspaceId}"`);
|
|
333
|
+
}
|
|
334
|
+
const backend = workspace.backend;
|
|
335
|
+
if (!isObject(backend)) {
|
|
336
|
+
throw new Error(`Manager ${context} response missing workspace.backend for "${workspaceId}"`);
|
|
337
|
+
}
|
|
338
|
+
if (!isNonEmptyString(backend.pod_name) || !isNonEmptyString(backend.actor_id)) {
|
|
339
|
+
throw new Error(`Manager ${context} response missing workspace.backend.pod_name or actor_id for "${workspaceId}"`);
|
|
340
|
+
}
|
|
341
|
+
return { pod_name: backend.pod_name.trim(), actor_id: backend.actor_id.trim() };
|
|
342
|
+
}
|
|
343
|
+
export class PaseoWorkspaceBackendClient {
|
|
344
|
+
endpoint;
|
|
345
|
+
identity;
|
|
346
|
+
token;
|
|
347
|
+
constructor(endpoint, identity, token) {
|
|
348
|
+
if (!token) {
|
|
349
|
+
throw new Error("PaseoWorkspaceBackendClient requires a bearer token. Set PASEO_TOKEN in your .env (or export it) before invoking remote commands.");
|
|
350
|
+
}
|
|
351
|
+
this.endpoint = endpoint;
|
|
352
|
+
this.identity = identity;
|
|
353
|
+
this.token = token;
|
|
354
|
+
}
|
|
355
|
+
base() {
|
|
356
|
+
return `${actorBase(this.endpoint, this.identity)}/workspace`;
|
|
357
|
+
}
|
|
358
|
+
async getWorkspace() {
|
|
359
|
+
return await httpJson(this.base(), { token: this.token });
|
|
360
|
+
}
|
|
361
|
+
async importProject(projectId, documents, replaceMissing = true) {
|
|
362
|
+
return await httpJson(`${this.base()}/projects/${encodeURIComponent(projectId)}/import`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
token: this.token,
|
|
365
|
+
body: JSON.stringify({ documents, replace_missing: replaceMissing }),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
async listProjectDocuments(projectId, options = {}) {
|
|
369
|
+
const params = new URLSearchParams();
|
|
370
|
+
if (options.prefix)
|
|
371
|
+
params.set("prefix", options.prefix);
|
|
372
|
+
const query = params.toString();
|
|
373
|
+
return await httpJson(`${this.base()}/projects/${encodeURIComponent(projectId)}/documents${query ? `?${query}` : ""}`, { token: this.token });
|
|
374
|
+
}
|
|
375
|
+
async getProjectDocument(projectId, documentPath) {
|
|
376
|
+
return await httpJson(`${this.base()}/projects/${encodeURIComponent(projectId)}/documents?path=${encodeURIComponent(documentPath)}`, { token: this.token });
|
|
377
|
+
}
|
|
378
|
+
async getProjectSnapshot(projectId) {
|
|
379
|
+
return await httpJson(`${this.base()}/projects/${encodeURIComponent(projectId)}/snapshot`, { token: this.token });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
export async function ensureManagerInit(repoRoot, options) {
|
|
383
|
+
const endpoint = options.endpoint.trim();
|
|
384
|
+
if (!endpoint) {
|
|
177
385
|
throw new Error("Remote init requires an endpoint");
|
|
178
386
|
}
|
|
387
|
+
const prior = options.existingConfig;
|
|
388
|
+
let podName = (options.managerPodName ?? prior?.manager.pod_name ?? "").trim();
|
|
389
|
+
let actorId = (options.managerActorId ?? prior?.manager.actor_id ?? "").trim();
|
|
390
|
+
let created_pod = false;
|
|
391
|
+
let created_actor = false;
|
|
392
|
+
let manager_auth_token;
|
|
179
393
|
if (!podName && !options.provision) {
|
|
180
|
-
throw new Error("Remote init requires --pod
|
|
394
|
+
throw new Error("Remote init requires --manager-pod (or --provision to create a fresh pod and manager actor)");
|
|
181
395
|
}
|
|
182
|
-
|
|
183
|
-
|
|
396
|
+
// createPod and createManagerActor do not require a bearer token; the manager actor itself issues the token.
|
|
397
|
+
const provisioner = new PaseoManagerClient(endpoint, {
|
|
184
398
|
pod_name: podName || "pending-pod",
|
|
185
399
|
actor_id: actorId || "pending-actor",
|
|
186
400
|
});
|
|
187
401
|
if (!podName) {
|
|
188
|
-
podName = await
|
|
402
|
+
podName = await provisioner.createPod();
|
|
189
403
|
created_pod = true;
|
|
190
404
|
}
|
|
191
405
|
if (!actorId) {
|
|
192
406
|
if (!options.provision) {
|
|
193
|
-
throw new Error("Remote init requires --actor
|
|
407
|
+
throw new Error("Remote init requires --manager-actor unless --provision is set so the CLI can create a fresh manager actor");
|
|
194
408
|
}
|
|
195
|
-
const
|
|
196
|
-
endpoint: options.endpoint.trim(),
|
|
409
|
+
const result = await new PaseoManagerClient(endpoint, {
|
|
197
410
|
pod_name: podName,
|
|
198
411
|
actor_id: "pending-actor",
|
|
199
|
-
});
|
|
200
|
-
actorId =
|
|
412
|
+
}).createManagerActor(podName);
|
|
413
|
+
actorId = result.actorId;
|
|
414
|
+
manager_auth_token = result.managerAuthToken;
|
|
201
415
|
created_actor = true;
|
|
202
416
|
}
|
|
203
417
|
const config = {
|
|
204
418
|
version: REMOTE_CONFIG_VERSION,
|
|
205
419
|
provider: REMOTE_PROVIDER,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
actor_id: actorId,
|
|
420
|
+
manager: { pod_name: podName, actor_id: actorId },
|
|
421
|
+
targets: prior?.targets ?? {},
|
|
209
422
|
};
|
|
210
423
|
await writeRemoteConfig(repoRoot, config);
|
|
211
|
-
return { config, created_pod, created_actor };
|
|
424
|
+
return { config, created_pod, created_actor, manager_auth_token };
|
|
212
425
|
}
|
|
213
|
-
export function
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
217
|
-
|
|
426
|
+
export async function ensureWorkspaceTarget(repoRoot, config, options) {
|
|
427
|
+
const manager = new PaseoManagerClient(options.endpoint, config.manager, options.token);
|
|
428
|
+
const existing = config.targets[options.workspaceId];
|
|
429
|
+
if (options.mode === "resolve" || (options.mode === "auto" && existing)) {
|
|
430
|
+
try {
|
|
431
|
+
const backend = await manager.getWorkspace(options.workspaceId);
|
|
432
|
+
const nextConfig = await upsertWorkspaceTarget(repoRoot, config, options.workspaceId, backend);
|
|
433
|
+
return { config: nextConfig, workspace_backend: backend, outcome: existing ? "reused" : "resolved" };
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
if (options.mode === "resolve")
|
|
437
|
+
throw error;
|
|
438
|
+
// fall through to create on auto
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// create (or auto with no existing)
|
|
442
|
+
try {
|
|
443
|
+
const backend = await manager.createWorkspace(options.workspaceId, options.metadata);
|
|
444
|
+
const nextConfig = await upsertWorkspaceTarget(repoRoot, config, options.workspaceId, backend);
|
|
445
|
+
return { config: nextConfig, workspace_backend: backend, outcome: "created" };
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (error instanceof RemoteHttpError && error.status === 409) {
|
|
449
|
+
const backend = await manager.getWorkspace(options.workspaceId);
|
|
450
|
+
const nextConfig = await upsertWorkspaceTarget(repoRoot, config, options.workspaceId, backend);
|
|
451
|
+
return { config: nextConfig, workspace_backend: backend, outcome: "resolved" };
|
|
452
|
+
}
|
|
453
|
+
throw error;
|
|
218
454
|
}
|
|
219
|
-
return { project_id, workspace_name };
|
|
220
455
|
}
|
|
221
|
-
export function
|
|
456
|
+
export function extractWorkspaceSummaryIdentity(summary) {
|
|
457
|
+
const top = summary;
|
|
458
|
+
const source = isObject(summary.source) ? summary.source : {};
|
|
222
459
|
const workspace = isObject(summary.workspace) ? summary.workspace : {};
|
|
223
460
|
const stats = isObject(summary.stats) ? summary.stats : {};
|
|
461
|
+
const actorType = typeof source.actor_type === "string"
|
|
462
|
+
? source.actor_type
|
|
463
|
+
: typeof top.actor_type === "string"
|
|
464
|
+
? top.actor_type
|
|
465
|
+
: null;
|
|
466
|
+
const backendVersion = typeof source.version === "string"
|
|
467
|
+
? source.version
|
|
468
|
+
: typeof top.version === "string"
|
|
469
|
+
? top.version
|
|
470
|
+
: null;
|
|
471
|
+
const documentCount = typeof stats.document_count === "number"
|
|
472
|
+
? stats.document_count
|
|
473
|
+
: typeof stats.source_document_count === "number"
|
|
474
|
+
? stats.source_document_count
|
|
475
|
+
: null;
|
|
224
476
|
return {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
477
|
+
workspace_id: typeof workspace.workspace_id === "string" ? workspace.workspace_id : null,
|
|
478
|
+
actor_type: actorType,
|
|
479
|
+
backend_version: backendVersion,
|
|
480
|
+
document_count: documentCount,
|
|
481
|
+
registered_project_count: typeof stats.registered_project_count === "number" ? stats.registered_project_count : null,
|
|
228
482
|
};
|
|
229
483
|
}
|
|
230
|
-
export function
|
|
231
|
-
const remote =
|
|
232
|
-
if (remote.
|
|
233
|
-
throw new Error(
|
|
484
|
+
export function assertWorkspaceSummaryIdentity(summary, expectedWorkspaceId) {
|
|
485
|
+
const remote = extractWorkspaceSummaryIdentity(summary);
|
|
486
|
+
if (!remote.workspace_id) {
|
|
487
|
+
throw new Error("Remote workspace summary did not include a workspace.workspace_id envelope. The linked actor may be a legacy v1 backend or a non-workspace actor. Reinitialize against reffyWorkspaceManager.v1 + reffyRemoteBackend.v2 (`reffy remote init`) and repush with `reffy remote push`.");
|
|
488
|
+
}
|
|
489
|
+
if (remote.workspace_id !== expectedWorkspaceId) {
|
|
490
|
+
throw new Error(`Remote identity mismatch: expected workspace_id=${expectedWorkspaceId} but workspace backend reported workspace_id=${String(remote.workspace_id)}. Re-resolve the workspace through the manager (\`reffy remote workspace get ${expectedWorkspaceId}\`) or reinitialize linkage.`);
|
|
234
491
|
}
|
|
235
492
|
}
|
|
236
493
|
export function validateImportResult(result) {
|
package/dist/storage.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare class ReferencesStore {
|
|
|
12
12
|
listArtifacts(): Promise<Artifact[]>;
|
|
13
13
|
getWorkspaceIdentity(): Promise<{
|
|
14
14
|
project_id?: string;
|
|
15
|
-
|
|
15
|
+
workspace_ids?: string[];
|
|
16
16
|
}>;
|
|
17
17
|
getArtifact(artifactId: string): Promise<Artifact | null>;
|
|
18
18
|
getArtifactPath(artifact: Artifact): string;
|
package/dist/storage.js
CHANGED
|
@@ -51,7 +51,7 @@ export class ReferencesStore {
|
|
|
51
51
|
const manifest = await this.readManifest();
|
|
52
52
|
return {
|
|
53
53
|
project_id: manifest.project_id,
|
|
54
|
-
|
|
54
|
+
workspace_ids: manifest.workspace_ids ? [...manifest.workspace_ids] : undefined,
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
async getArtifact(artifactId) {
|
package/dist/types.d.ts
CHANGED
|
@@ -17,17 +17,25 @@ export interface Manifest {
|
|
|
17
17
|
created_at: string;
|
|
18
18
|
updated_at: string;
|
|
19
19
|
project_id?: string;
|
|
20
|
+
workspace_ids?: string[];
|
|
21
|
+
/** Deprecated v1 field retained only as migration input. */
|
|
20
22
|
workspace_name?: string;
|
|
21
23
|
artifacts: Artifact[];
|
|
22
24
|
}
|
|
23
|
-
export interface
|
|
24
|
-
version: number;
|
|
25
|
-
provider: "paseo";
|
|
26
|
-
endpoint: string;
|
|
25
|
+
export interface RemoteActorIdentity {
|
|
27
26
|
pod_name: string;
|
|
28
27
|
actor_id: string;
|
|
28
|
+
}
|
|
29
|
+
export interface RemoteTarget {
|
|
30
|
+
workspace_backend: RemoteActorIdentity;
|
|
29
31
|
last_imported_at?: string;
|
|
30
32
|
}
|
|
33
|
+
export interface RemoteLinkConfig {
|
|
34
|
+
version: number;
|
|
35
|
+
provider: "paseo";
|
|
36
|
+
manager: RemoteActorIdentity;
|
|
37
|
+
targets: Record<string, RemoteTarget>;
|
|
38
|
+
}
|
|
31
39
|
export interface RemoteDocument {
|
|
32
40
|
path: string;
|
|
33
41
|
content: string;
|
|
@@ -41,6 +49,7 @@ export interface RemoteImportResult {
|
|
|
41
49
|
deleted?: number;
|
|
42
50
|
}
|
|
43
51
|
export interface RemoteWorkspaceSummary {
|
|
52
|
+
source?: Record<string, unknown>;
|
|
44
53
|
workspace?: Record<string, unknown>;
|
|
45
54
|
stats?: Record<string, unknown>;
|
|
46
55
|
}
|
package/dist/workspace.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { normalizeManifest } from "./manifest.js";
|
|
4
4
|
import { DEFAULT_REFS_DIRNAME, LEGACY_REFS_DIRNAME, detectWorkspaceState, resolveCanonicalRefsDir, } from "./refs-paths.js";
|
|
5
5
|
async function pathExists(targetPath) {
|
|
6
6
|
try {
|
|
@@ -30,11 +30,13 @@ async function ensureCanonicalStructure(repoRoot) {
|
|
|
30
30
|
const raw = JSON.parse(rawText);
|
|
31
31
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
32
32
|
const record = raw;
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
33
|
+
const missingProjectId = record.project_id === undefined;
|
|
34
|
+
const missingWorkspaceIds = !Array.isArray(record.workspace_ids) || record.workspace_ids.length === 0;
|
|
35
|
+
const needsMigration = missingProjectId || missingWorkspaceIds;
|
|
36
|
+
if (needsMigration) {
|
|
36
37
|
const nextManifest = normalizeManifest(raw, repoRoot);
|
|
37
38
|
nextManifest.updated_at = new Date().toISOString();
|
|
39
|
+
delete nextManifest.workspace_name;
|
|
38
40
|
await fs.writeFile(manifestPath, JSON.stringify(nextManifest, null, 2), "utf8");
|
|
39
41
|
}
|
|
40
42
|
}
|