reffy-cli 1.5.0 → 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,10 +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 = 2;
4
+ export const REMOTE_CONFIG_VERSION = 4;
5
5
  export const REMOTE_PROVIDER = "paseo";
6
6
  export const REMOTE_BACKEND_ACTOR_TYPE = "reffyRemoteBackend";
7
7
  export const REMOTE_BACKEND_VERSION = "v2";
8
+ export const REMOTE_MANAGER_ACTOR_TYPE = "reffyWorkspaceManager";
9
+ export const REMOTE_MANAGER_VERSION = "v1";
8
10
  const REMOTE_STATE_DIR = path.join("state");
9
11
  const REMOTE_CONFIG_FILE = "remote.json";
10
12
  function isObject(value) {
@@ -16,6 +18,18 @@ function isNonEmptyString(value) {
16
18
  function normalizeSeparators(value) {
17
19
  return value.split(path.sep).join("/");
18
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
+ }
19
33
  export function resolveRemoteConfigPath(repoRoot) {
20
34
  return path.join(resolveRefsDir(repoRoot), REMOTE_STATE_DIR, REMOTE_CONFIG_FILE);
21
35
  }
@@ -70,15 +84,19 @@ export async function readRemoteConfig(repoRoot) {
70
84
  if (parsed.provider !== REMOTE_PROVIDER) {
71
85
  throw new Error(`Remote config at ${configPath} must use provider "${REMOTE_PROVIDER}"`);
72
86
  }
73
- if (!isNonEmptyString(parsed.endpoint)) {
74
- throw new Error(`Remote config at ${configPath} must define endpoint`);
75
- }
76
87
  if (parsed.version === 1) {
77
- throw new Error(`Remote config at ${configPath} uses the legacy v1 single-target shape. Run \`reffy remote init --provision\` against the v2 backend to reinitialize, then repush with \`reffy remote push\`.`);
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\`.`);
78
95
  }
79
96
  if (parsed.version !== REMOTE_CONFIG_VERSION) {
80
97
  throw new Error(`Remote config at ${configPath} must use version ${String(REMOTE_CONFIG_VERSION)}`);
81
98
  }
99
+ const manager = readActorIdentity(parsed.manager, "manager", configPath);
82
100
  if (!isObject(parsed.targets)) {
83
101
  throw new Error(`Remote config at ${configPath} must define targets as an object keyed by workspace_id`);
84
102
  }
@@ -87,22 +105,16 @@ export async function readRemoteConfig(repoRoot) {
87
105
  if (!isObject(entry)) {
88
106
  throw new Error(`Remote config at ${configPath} has invalid target for workspace_id "${workspaceId}"`);
89
107
  }
90
- if (!isNonEmptyString(entry.pod_name)) {
91
- throw new Error(`Remote config target "${workspaceId}" must define pod_name`);
92
- }
93
- if (!isNonEmptyString(entry.actor_id)) {
94
- throw new Error(`Remote config target "${workspaceId}" must define actor_id`);
95
- }
108
+ const workspace_backend = readActorIdentity(entry.workspace_backend, `targets.${workspaceId}.workspace_backend`, configPath);
96
109
  targets[workspaceId] = {
97
- pod_name: entry.pod_name.trim(),
98
- actor_id: entry.actor_id.trim(),
110
+ workspace_backend,
99
111
  last_imported_at: isNonEmptyString(entry.last_imported_at) ? entry.last_imported_at.trim() : undefined,
100
112
  };
101
113
  }
102
114
  return {
103
115
  version: REMOTE_CONFIG_VERSION,
104
116
  provider: REMOTE_PROVIDER,
105
- endpoint: parsed.endpoint.trim(),
117
+ manager,
106
118
  targets,
107
119
  };
108
120
  }
@@ -129,33 +141,79 @@ export async function updateRemoteConfigMetadata(repoRoot, workspaceId, patch) {
129
141
  await writeRemoteConfig(repoRoot, next);
130
142
  return next;
131
143
  }
132
- export function resolveRemoteTarget(stored, workspaceId, overrides) {
133
- const endpoint = overrides.endpoint ?? stored?.endpoint;
134
- const target = stored?.targets?.[workspaceId];
135
- const pod_name = overrides.pod_name ?? target?.pod_name;
136
- const actor_id = overrides.actor_id ?? target?.actor_id;
137
- if (!endpoint || !pod_name || !actor_id) {
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 };
148
+ }
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)
138
175
  return null;
139
- }
140
176
  return {
141
177
  endpoint,
142
- pod_name,
143
- actor_id,
144
- last_imported_at: target?.last_imported_at,
178
+ manager: stored.manager,
179
+ workspace_backend: target.workspace_backend,
180
+ last_imported_at: target.last_imported_at,
145
181
  };
146
182
  }
147
183
  export function describeRemoteLinkage(linkage) {
148
- return `endpoint=${linkage.endpoint} pod=${linkage.pod_name} actor=${linkage.actor_id} workspace=${linkage.workspace_id}`;
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
+ }
149
203
  }
150
204
  async function http(url, init = {}) {
151
- const headers = new Headers(init.headers ?? {});
152
- 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) {
153
208
  headers.set("Content-Type", "application/json");
154
209
  }
155
- 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 });
156
214
  if (!response.ok) {
157
215
  const text = await response.text().catch(() => "");
158
- throw new Error(`${response.status} ${response.statusText} - ${text}`);
216
+ throw new RemoteHttpError(response.status, response.statusText, text, url, rest.method ?? "GET");
159
217
  }
160
218
  return response;
161
219
  }
@@ -163,136 +221,273 @@ async function httpJson(url, init = {}) {
163
221
  const response = await http(url, init);
164
222
  return (await response.json());
165
223
  }
166
- function actorBase(config) {
167
- return `${config.endpoint}/pods/${config.pod_name}/actors/${config.actor_id}`;
168
- }
169
- function workspaceBase(config, workspaceId) {
170
- return `${actorBase(config)}/workspaces/${encodeURIComponent(workspaceId)}`;
224
+ function actorBase(endpoint, identity) {
225
+ return `${endpoint}/pods/${identity.pod_name}/actors/${identity.actor_id}`;
171
226
  }
172
- export class PaseoRemoteClient {
173
- config;
174
- constructor(config) {
175
- 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;
176
244
  }
177
245
  async createPod() {
178
- const result = await httpJson(`${this.config.endpoint}/pods`, { method: "POST" });
246
+ const result = await httpJson(`${this.endpoint}/pods`, { method: "POST" });
179
247
  if (!isNonEmptyString(result.podName)) {
180
248
  throw new Error("Paseo create pod response missing podName");
181
249
  }
182
250
  return result.podName.trim();
183
251
  }
184
- async createActor(identity) {
185
- 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`, {
186
254
  method: "POST",
187
255
  body: JSON.stringify({
188
256
  config: {
189
- actorType: REMOTE_BACKEND_ACTOR_TYPE,
190
- version: REMOTE_BACKEND_VERSION,
257
+ actorType: REMOTE_MANAGER_ACTOR_TYPE,
258
+ version: REMOTE_MANAGER_VERSION,
191
259
  schema: { type: "object" },
192
- params: {
193
- project_id: identity.project_id,
194
- workspace_ids: [identity.workspace_id],
195
- default_lock_ttl_seconds: 120,
196
- max_snapshot_documents: 1000,
197
- },
260
+ params: {},
198
261
  },
199
262
  }),
200
263
  });
201
264
  if (!isNonEmptyString(result.actorId)) {
202
- throw new Error("Paseo create actor response missing actorId");
265
+ throw new Error("Paseo create manager actor response missing actorId");
203
266
  }
204
- return result.actorId.trim();
205
- }
206
- async getWorkspace(workspaceId) {
207
- return await httpJson(workspaceBase(this.config, workspaceId));
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() };
208
271
  }
209
- async importWorkspace(workspaceId, documents, replaceMissing = true) {
210
- return await httpJson(`${workspaceBase(this.config, workspaceId)}/import`, {
272
+ async createWorkspace(workspaceId, metadata) {
273
+ const result = await httpJson(`${this.base()}/workspaces`, {
211
274
  method: "POST",
275
+ token: this.requireToken(),
212
276
  body: JSON.stringify({
213
- documents,
214
- replace_missing: replaceMissing,
277
+ workspace_id: workspaceId,
278
+ metadata: metadata ?? {},
279
+ controller: { client: "reffy-cli" },
215
280
  }),
216
281
  });
282
+ return extractWorkspaceBackendIdentity(result, workspaceId, "createWorkspace");
217
283
  }
218
- async getSnapshot(workspaceId, prefix) {
219
- const query = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
220
- return await httpJson(`${workspaceBase(this.config, workspaceId)}/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");
287
+ }
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
+ }
221
306
  }
222
- async getDocument(workspaceId, documentPath) {
223
- return await httpJson(`${workspaceBase(this.config, workspaceId)}/documents?path=${encodeURIComponent(documentPath)}`);
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
+ }
224
327
  }
225
328
  }
226
- export async function ensureRemoteInit(repoRoot, options) {
227
- let created_pod = false;
228
- let created_actor = false;
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) {
229
383
  const endpoint = options.endpoint.trim();
230
384
  if (!endpoint) {
231
385
  throw new Error("Remote init requires an endpoint");
232
386
  }
233
- const priorTarget = options.existingConfig?.targets[options.identity.workspace_id] ?? null;
234
- let podName = options.podName?.trim() || priorTarget?.pod_name || "";
235
- let actorId = options.actorId?.trim() || priorTarget?.actor_id || "";
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;
236
393
  if (!podName && !options.provision) {
237
- 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)");
238
395
  }
239
- const baseClient = new PaseoRemoteClient({
240
- endpoint,
396
+ // createPod and createManagerActor do not require a bearer token; the manager actor itself issues the token.
397
+ const provisioner = new PaseoManagerClient(endpoint, {
241
398
  pod_name: podName || "pending-pod",
242
399
  actor_id: actorId || "pending-actor",
243
400
  });
244
401
  if (!podName) {
245
- podName = await baseClient.createPod();
402
+ podName = await provisioner.createPod();
246
403
  created_pod = true;
247
404
  }
248
405
  if (!actorId) {
249
406
  if (!options.provision) {
250
- 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");
251
408
  }
252
- const client = new PaseoRemoteClient({
253
- endpoint,
409
+ const result = await new PaseoManagerClient(endpoint, {
254
410
  pod_name: podName,
255
411
  actor_id: "pending-actor",
256
- });
257
- actorId = await client.createActor(options.identity);
412
+ }).createManagerActor(podName);
413
+ actorId = result.actorId;
414
+ manager_auth_token = result.managerAuthToken;
258
415
  created_actor = true;
259
416
  }
260
- const nextTarget = {
261
- pod_name: podName,
262
- actor_id: actorId,
263
- last_imported_at: priorTarget?.last_imported_at,
264
- };
265
417
  const config = {
266
418
  version: REMOTE_CONFIG_VERSION,
267
419
  provider: REMOTE_PROVIDER,
268
- endpoint,
269
- targets: {
270
- ...(options.existingConfig?.targets ?? {}),
271
- [options.identity.workspace_id]: nextTarget,
272
- },
420
+ manager: { pod_name: podName, actor_id: actorId },
421
+ targets: prior?.targets ?? {},
273
422
  };
274
423
  await writeRemoteConfig(repoRoot, config);
275
- return { config, target: nextTarget, created_pod, created_actor };
424
+ return { config, created_pod, created_actor, manager_auth_token };
425
+ }
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;
454
+ }
276
455
  }
277
- export function extractWorkspaceIdentity(summary) {
456
+ export function extractWorkspaceSummaryIdentity(summary) {
457
+ const top = summary;
278
458
  const source = isObject(summary.source) ? summary.source : {};
279
459
  const workspace = isObject(summary.workspace) ? summary.workspace : {};
280
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;
281
476
  return {
282
- project_id: typeof source.project_id === "string" ? source.project_id : null,
283
477
  workspace_id: typeof workspace.workspace_id === "string" ? workspace.workspace_id : null,
284
- actor_type: typeof source.actor_type === "string" ? source.actor_type : null,
285
- backend_version: typeof source.version === "string" ? source.version : null,
286
- document_count: typeof stats.document_count === "number" ? stats.document_count : 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,
287
482
  };
288
483
  }
289
- export function assertRemoteIdentity(summary, identity) {
290
- const remote = extractWorkspaceIdentity(summary);
484
+ export function assertWorkspaceSummaryIdentity(summary, expectedWorkspaceId) {
485
+ const remote = extractWorkspaceSummaryIdentity(summary);
291
486
  if (!remote.workspace_id) {
292
- throw new Error("Remote response did not include a workspace.workspace_id envelope. This likely means the linked actor is a legacy v1 backend. Reinitialize the target against reffyRemoteBackend.v2 (`reffy remote init --provision --workspace-id <id>`) and repush with `reffy remote push`.");
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`.");
293
488
  }
294
- if (remote.project_id !== identity.project_id || remote.workspace_id !== identity.workspace_id) {
295
- throw new Error(`Remote identity mismatch: local=${identity.project_id}/${identity.workspace_id} remote=${String(remote.project_id)}/${String(remote.workspace_id)}`);
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.`);
296
491
  }
297
492
  }
298
493
  export function validateImportResult(result) {
package/dist/types.d.ts CHANGED
@@ -22,15 +22,18 @@ export interface Manifest {
22
22
  workspace_name?: string;
23
23
  artifacts: Artifact[];
24
24
  }
25
- export interface RemoteTarget {
25
+ export interface RemoteActorIdentity {
26
26
  pod_name: string;
27
27
  actor_id: string;
28
+ }
29
+ export interface RemoteTarget {
30
+ workspace_backend: RemoteActorIdentity;
28
31
  last_imported_at?: string;
29
32
  }
30
33
  export interface RemoteLinkConfig {
31
34
  version: number;
32
35
  provider: "paseo";
33
- endpoint: string;
36
+ manager: RemoteActorIdentity;
34
37
  targets: Record<string, RemoteTarget>;
35
38
  }
36
39
  export interface RemoteDocument {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy-cli",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Planning system for AI assisted development.",
5
5
  "keywords": [
6
6
  "reffy",