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/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 = 1;
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 (!isNonEmptyString(parsed.endpoint)) {
42
- throw new Error(`Remote config at ${configPath} must define endpoint`);
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
- if (!isNonEmptyString(parsed.pod_name)) {
45
- throw new Error(`Remote config at ${configPath} must define pod_name`);
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
- if (!isNonEmptyString(parsed.actor_id)) {
48
- throw new Error(`Remote config at ${configPath} must define actor_id`);
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
- endpoint: parsed.endpoint.trim(),
54
- pod_name: parsed.pod_name.trim(),
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
- ...patch,
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 mergeRemoteConfig(stored, overrides) {
77
- if (!stored && !overrides.endpoint && !overrides.pod_name && !overrides.actor_id) {
78
- return null;
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 endpoint = overrides.endpoint ?? stored?.endpoint;
81
- const pod_name = overrides.pod_name ?? stored?.pod_name;
82
- const actor_id = overrides.actor_id ?? stored?.actor_id;
83
- if (!endpoint || !pod_name || !actor_id) {
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
- pod_name,
91
- actor_id,
92
- last_imported_at: stored?.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(config) {
96
- return `endpoint=${config.endpoint} pod=${config.pod_name} actor=${config.actor_id}`;
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 = new Headers(init.headers ?? {});
100
- if (!headers.has("Content-Type") && init.body) {
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
- const response = await fetch(url, { ...init, headers });
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 Error(`${response.status} ${response.statusText} - ${text}`);
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(config) {
115
- return `${config.endpoint}/pods/${config.pod_name}/actors/${config.actor_id}`;
224
+ function actorBase(endpoint, identity) {
225
+ return `${endpoint}/pods/${identity.pod_name}/actors/${identity.actor_id}`;
116
226
  }
117
- export class PaseoRemoteClient {
118
- config;
119
- constructor(config) {
120
- this.config = config;
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.config.endpoint}/pods`, { method: "POST" });
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 createActor(identity) {
130
- const result = await httpJson(`${this.config.endpoint}/pods/${this.config.pod_name}/actors`, {
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: "reffyRemoteBackend",
135
- version: "v1",
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
- return result.actorId.trim();
150
- }
151
- async getWorkspace() {
152
- return await httpJson(`${actorBase(this.config)}/workspace`);
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 importWorkspace(documents, replaceMissing = true) {
155
- return await httpJson(`${actorBase(this.config)}/workspace/import`, {
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
- documents,
159
- replace_missing: replaceMissing,
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 getSnapshot(prefix) {
164
- const query = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
165
- return await httpJson(`${actorBase(this.config)}/workspace/snapshot${query}`);
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 getDocument(documentPath) {
168
- return await httpJson(`${actorBase(this.config)}/workspace/documents?path=${encodeURIComponent(documentPath)}`);
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
- export async function ensureRemoteInit(repoRoot, options) {
172
- let created_pod = false;
173
- let created_actor = false;
174
- let podName = options.podName?.trim() || "";
175
- let actorId = options.actorId?.trim() || "";
176
- if (!options.endpoint.trim()) {
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-name unless --provision is set");
394
+ throw new Error("Remote init requires --manager-pod (or --provision to create a fresh pod and manager actor)");
181
395
  }
182
- const baseClient = new PaseoRemoteClient({
183
- endpoint: options.endpoint.trim(),
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 baseClient.createPod();
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-id unless --provision is set");
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 client = new PaseoRemoteClient({
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 = await client.createActor(options.identity);
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
- endpoint: options.endpoint.trim(),
207
- pod_name: podName,
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 requireWorkspaceIdentity(identity) {
214
- const project_id = identity.project_id?.trim();
215
- const workspace_name = identity.workspace_name?.trim();
216
- if (!project_id || !workspace_name) {
217
- throw new Error("Remote sync requires .reffy/manifest.json to define project_id and workspace_name");
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 extractWorkspaceIdentity(summary) {
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
- project_id: typeof workspace.project_id === "string" ? workspace.project_id : null,
226
- workspace_name: typeof workspace.workspace_name === "string" ? workspace.workspace_name : null,
227
- document_count: typeof stats.document_count === "number" ? stats.document_count : null,
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 assertRemoteIdentity(summary, identity) {
231
- const remote = extractWorkspaceIdentity(summary);
232
- if (remote.project_id !== identity.project_id || remote.workspace_name !== identity.workspace_name) {
233
- throw new Error(`Remote identity mismatch: local=${identity.project_id}/${identity.workspace_name} remote=${String(remote.project_id)}/${String(remote.workspace_name)}`);
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
- workspace_name?: string;
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
- workspace_name: manifest.workspace_name,
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 RemoteLinkConfig {
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 { deriveManifestIdentity, normalizeManifest } from "./manifest.js";
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 defaults = deriveManifestIdentity(repoRoot);
34
- const missingIdentity = record.project_id === undefined || record.workspace_name === undefined;
35
- if (missingIdentity) {
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
  }