reffy-cli 1.4.2 → 1.5.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 CHANGED
@@ -65,42 +65,20 @@ reffy diagram render --input .reffy/reffyspec/specs/auth/spec.md --format ascii
65
65
  reffy diagram render --input .reffy/reffyspec/specs/auth/spec.md --format svg --output .reffy/artifacts/auth-spec.svg
66
66
  ```
67
67
 
68
- ## Remote Backend Helper
69
-
70
- Reffy includes a Paseo helper bridge at `scripts/reffy-remote-backend-demo.mjs`.
71
-
72
- The helper:
73
-
74
- - loads `.env` automatically when present
75
- - reads `project_id` and `workspace_name` from `.reffy/manifest.json`
76
- - provisions or links a Paseo `reffyRemoteBackend` actor
77
- - imports the local `.reffy/` workspace
78
-
79
- Expected environment variables:
80
-
81
- - `PASEO_ENDPOINT` - required unless already exported in the shell
82
- - `PASEO_POD_NAME` - optional existing pod override
83
- - `PASEO_ACTOR_ID` - optional existing actor override
84
-
85
- Examples:
86
-
87
- ```bash
88
- npm run remote:backend:demo
89
- reffy remote init --provision
90
- reffy remote push
91
- ```
92
-
93
68
  ## Remote Sync
94
69
 
95
70
  Reffy can publish the local `.reffy/` workspace to a Paseo-backed remote actor and inspect it later with native CLI commands.
96
71
 
97
72
  The current remote flow is:
98
73
 
99
- 1. Reffy reads `project_id` and `workspace_name` from `.reffy/manifest.json`.
100
- 2. Reffy connects to Paseo using `PASEO_ENDPOINT` or `--endpoint`.
101
- 3. `reffy remote init --provision` creates a pod and actor when needed, then writes local linkage state to `.reffy/state/remote.json`.
102
- 4. `reffy remote push` publishes the local workspace to that linked actor with `replace_missing=true` by default, so remote reflects local.
103
- 5. `reffy remote status|ls|cat` inspects the linked remote workspace using the saved linkage state unless you override it.
74
+ 1. Reffy reads `project_id` and `workspace_ids` from `.reffy/manifest.json`.
75
+ 2. You select one workspace projection to act on. Reffy infers the selection when `workspace_ids` has exactly one entry; otherwise pass `--workspace-id <id>`.
76
+ 3. Reffy connects to Paseo using `PASEO_ENDPOINT` or `--endpoint`.
77
+ 4. `reffy remote init --provision` creates a pod and a `reffyRemoteBackend.v2` actor for the selected workspace id when needed, then writes local linkage state to `.reffy/state/remote.json`.
78
+ 5. `reffy remote push` publishes the full local `.reffy/` tree to the selected projection through `/workspaces/{workspace_id}/...` with `replace_missing=true` by default, scoped to that workspace id.
79
+ 6. `reffy remote status|ls|cat` inspects the selected workspace projection using the saved linkage state unless you override it.
80
+
81
+ One backend actor can serve multiple workspace projections for the same source `project_id`: Reffy stores one target per `workspace_id` in `.reffy/state/remote.json`, and the backend isolates documents and locks by `(workspace_id, path)`.
104
82
 
105
83
  ### Minimal connection requirement
106
84
 
@@ -124,8 +102,9 @@ Optional overrides:
124
102
 
125
103
  - `PASEO_POD_NAME` if you want to reuse an existing Paseo pod
126
104
  - `PASEO_ACTOR_ID` if you want to reuse an existing backend actor
105
+ - `--workspace-id <id>` (or `PASEO_WORKSPACE_ID` for the helper script) when the manifest lists more than one `workspace_ids`
127
106
 
128
- Reffy does not require separate `REFFY_PROJECT_ID` or `REFFY_WORKSPACE_NAME` values for the normal case because those come from `.reffy/manifest.json`.
107
+ Reffy does not require separate `REFFY_PROJECT_ID` values for the normal case because source identity comes from `.reffy/manifest.json`.
129
108
 
130
109
  ### Saved remote linkage
131
110
 
@@ -133,11 +112,21 @@ After `reffy remote init`, Reffy stores the local pointer to the linked backend
133
112
 
134
113
  - `.reffy/state/remote.json`
135
114
 
136
- That file contains the connection tuple Reffy uses to reach the backend:
115
+ That file maps each selected `workspace_id` to the backend target Reffy should use for that projection:
137
116
 
138
- - `endpoint`
139
- - `pod_name`
140
- - `actor_id`
117
+ ```json
118
+ {
119
+ "version": 2,
120
+ "provider": "paseo",
121
+ "endpoint": "https://paseo.example",
122
+ "targets": {
123
+ "my-project": { "pod_name": "...", "actor_id": "...", "last_imported_at": "..." },
124
+ "portfolio-alpha": { "pod_name": "...", "actor_id": "..." }
125
+ }
126
+ }
127
+ ```
128
+
129
+ Multiple targets can share the same `pod_name` and `actor_id` — the backend addresses each projection separately through `/workspaces/{workspace_id}/...`.
141
130
 
142
131
  This file is local runtime state. It is not part of the synced remote workspace and is intentionally excluded from `reffy remote push`.
143
132
 
@@ -156,9 +145,9 @@ reffy remote cat .reffy/manifest.json
156
145
 
157
146
  `reffy remote status` is the primary diagnostic command for this flow. It reports:
158
147
 
159
- - the saved linkage being used
160
- - the local manifest identity
161
- - the remote workspace identity
148
+ - the saved linkage being used for the selected workspace id
149
+ - the local manifest identity (source `project_id` and selected `workspace_id`)
150
+ - the remote identity returned from v2 (`source.actor_type`, `source.version`, `source.project_id`, `workspace.workspace_id`)
162
151
  - remote document counts when reachable
163
152
 
164
153
  `reffy remote push` reports:
@@ -173,7 +162,7 @@ That makes the default prune/import behavior auditable without dropping to direc
173
162
 
174
163
  ## Manifest Contract
175
164
 
176
- Reffy keeps workspace metadata in `.reffy/manifest.json`. New managed manifests include both core timestamps and workspace identity:
165
+ Reffy keeps workspace metadata in `.reffy/manifest.json`. New managed manifests separate stable source identity (`project_id`) from plural workspace membership (`workspace_ids`):
177
166
 
178
167
  ```json
179
168
  {
@@ -181,12 +170,21 @@ Reffy keeps workspace metadata in `.reffy/manifest.json`. New managed manifests
181
170
  "created_at": "2026-04-18T00:00:00.000Z",
182
171
  "updated_at": "2026-04-18T00:00:00.000Z",
183
172
  "project_id": "my-project",
184
- "workspace_name": "my-project",
173
+ "workspace_ids": ["my-project"],
185
174
  "artifacts": []
186
175
  }
187
176
  ```
188
177
 
189
- Older v1 manifests without `project_id` and `workspace_name` still validate. Running `reffy init` migrates managed manifests by adding those fields with deterministic defaults derived from the repository name.
178
+ A single `.reffy/` tree can belong to more than one planning workspace:
179
+
180
+ ```json
181
+ {
182
+ "project_id": "my-project",
183
+ "workspace_ids": ["my-project", "portfolio-alpha", "nuveris-cross-project-planning"]
184
+ }
185
+ ```
186
+
187
+ Running `reffy init` populates `project_id` and `workspace_ids` with deterministic defaults derived from the repository name for any managed manifest that is missing them.
190
188
 
191
189
  ## Using Reffy With ReffySpec
192
190
 
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { archivePlanningChange } from "./plan-archive.js";
10
10
  import { createPlanScaffold } from "./plan.js";
11
11
  import { DEFAULT_PLANNING_RELATIVE_DIR, looksLikePlanningDir, resolveCanonicalPlanningPath } from "./planning-paths.js";
12
12
  import { listPlanningChanges, showPlanningChange, validatePlanningChange } from "./plan-runtime.js";
13
- import { assertRemoteIdentity, collectWorkspaceDocuments, describeRemoteLinkage, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, requireWorkspaceIdentity, resolveRemoteConfigPath, updateRemoteConfigMetadata, validateImportResult, } from "./remote.js";
13
+ import { assertRemoteIdentity, collectWorkspaceDocuments, describeRemoteLinkage, ensureRemoteInit, extractWorkspaceIdentity, PaseoRemoteClient, readRemoteConfig, requireWorkspaceIdentity, resolveRemoteConfigPath, resolveRemoteTarget, resolveSelectedWorkspaceId, updateRemoteConfigMetadata, validateImportResult, } from "./remote.js";
14
14
  import { DEFAULT_REFS_DIRNAME, detectWorkspaceState, looksLikeRefsDir } from "./refs-paths.js";
15
15
  import { prepareCanonicalPlanningLayout } from "./planning-workspace.js";
16
16
  import { ReferencesStore } from "./storage.js";
@@ -485,16 +485,17 @@ function planUsage() {
485
485
  function remoteUsage() {
486
486
  return [
487
487
  "Usage:",
488
- " reffy remote init [--repo PATH] [--output text|json] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--provision] [--env-file PATH]",
489
- " reffy remote status [--repo PATH] [--output text|json] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
490
- " reffy remote push [--repo PATH] [--output text|json] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
491
- " reffy remote ls [--repo PATH] [--output text|json] [--prefix PATH] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
492
- " reffy remote cat <path> [--repo PATH] [--output text|json] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
488
+ " reffy remote init [--repo PATH] [--output text|json] [--workspace-id ID] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--provision] [--env-file PATH]",
489
+ " reffy remote status [--repo PATH] [--output text|json] [--workspace-id ID] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
490
+ " reffy remote push [--repo PATH] [--output text|json] [--workspace-id ID] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
491
+ " reffy remote ls [--repo PATH] [--output text|json] [--workspace-id ID] [--prefix PATH] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
492
+ " reffy remote cat <path> [--repo PATH] [--output text|json] [--workspace-id ID] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
493
493
  "",
494
494
  "Options:",
495
+ " --workspace-id ID Selected workspace projection. Required when the manifest has multiple workspace_ids; otherwise inferred.",
495
496
  " --endpoint URL Paseo base URL. Defaults to saved config or PASEO_ENDPOINT/.env.",
496
- " --pod-name NAME Paseo pod name. Defaults to saved config or PASEO_POD_NAME/.env.",
497
- " --actor-id ID Paseo actor id. Defaults to saved config or PASEO_ACTOR_ID/.env.",
497
+ " --pod-name NAME Paseo pod name. Defaults to saved target for the selected workspace id or PASEO_POD_NAME/.env.",
498
+ " --actor-id ID Paseo actor id. Defaults to saved target for the selected workspace id or PASEO_ACTOR_ID/.env.",
498
499
  " --provision Create missing pod/actor during `remote init`.",
499
500
  " --prefix PATH Prefix filter for `remote ls`.",
500
501
  " --env-file PATH Dotenv file to load before resolving env-backed options. Defaults to .env.",
@@ -758,6 +759,18 @@ function parseRemoteArgs(argv) {
758
759
  args.prefix = arg.split("=", 2)[1];
759
760
  continue;
760
761
  }
762
+ if (arg === "--workspace-id") {
763
+ const value = argv[i + 1];
764
+ if (!value)
765
+ throw new Error("--workspace-id requires a value");
766
+ args.workspaceId = value;
767
+ i += 1;
768
+ continue;
769
+ }
770
+ if (arg.startsWith("--workspace-id=")) {
771
+ args.workspaceId = arg.split("=", 2)[1];
772
+ continue;
773
+ }
761
774
  if (arg === "--provision") {
762
775
  args.provision = true;
763
776
  continue;
@@ -778,7 +791,8 @@ function getRemotePositionalArgs(argv) {
778
791
  arg === "--pod-name" ||
779
792
  arg === "--actor-id" ||
780
793
  arg === "--env-file" ||
781
- arg === "--prefix") {
794
+ arg === "--prefix" ||
795
+ arg === "--workspace-id") {
782
796
  i += 1;
783
797
  continue;
784
798
  }
@@ -789,6 +803,7 @@ function getRemotePositionalArgs(argv) {
789
803
  arg.startsWith("--actor-id=") ||
790
804
  arg.startsWith("--env-file=") ||
791
805
  arg.startsWith("--prefix=") ||
806
+ arg.startsWith("--workspace-id=") ||
792
807
  arg === "--json" ||
793
808
  arg === "--provision") {
794
809
  continue;
@@ -809,30 +824,48 @@ function printSection(title, values) {
809
824
  console.log(`- ${value}`);
810
825
  }
811
826
  }
812
- async function resolveRemoteContext(args) {
827
+ async function resolveRemoteContext(args, opts = {}) {
813
828
  await loadDotEnvIfPresent(args.repoRoot, args.envFile ?? ".env");
814
829
  const store = new ReferencesStore(args.repoRoot);
815
- const identity = requireWorkspaceIdentity(await store.getWorkspaceIdentity());
816
- const savedConfig = await readRemoteConfig(args.repoRoot);
830
+ const identityInfo = await store.getWorkspaceIdentity();
831
+ const workspaceId = resolveSelectedWorkspaceId(identityInfo, args.workspaceId);
832
+ const identity = requireWorkspaceIdentity(identityInfo, workspaceId);
833
+ let savedConfig = null;
834
+ let replacedLegacy = false;
835
+ try {
836
+ savedConfig = await readRemoteConfig(args.repoRoot);
837
+ }
838
+ catch (error) {
839
+ const message = error instanceof Error ? error.message : String(error);
840
+ if (opts.tolerateLegacyV1Config && message.includes("legacy v1 single-target shape")) {
841
+ replacedLegacy = true;
842
+ }
843
+ else {
844
+ throw error;
845
+ }
846
+ }
817
847
  const overrideValues = {
818
848
  endpoint: args.endpoint ?? process.env.PASEO_ENDPOINT,
819
849
  pod_name: args.podName ?? process.env.PASEO_POD_NAME,
820
850
  actor_id: args.actorId ?? process.env.PASEO_ACTOR_ID,
821
851
  };
822
- const mergedConfig = mergeRemoteConfig(savedConfig, overrideValues);
852
+ const resolvedTarget = resolveRemoteTarget(savedConfig, workspaceId, overrideValues);
823
853
  const hasOverrides = Boolean(overrideValues.endpoint || overrideValues.pod_name || overrideValues.actor_id);
824
- const linkageSource = mergedConfig
825
- ? savedConfig && !hasOverrides
854
+ const savedHasTarget = Boolean(savedConfig?.targets?.[workspaceId]);
855
+ const linkageSource = resolvedTarget
856
+ ? savedHasTarget && !hasOverrides
826
857
  ? "saved_config"
827
858
  : "cli_or_env_overrides"
828
859
  : "unconfigured";
829
860
  return {
830
861
  store,
831
862
  identity,
863
+ workspaceId,
832
864
  configPath: resolveRemoteConfigPath(args.repoRoot),
833
865
  savedConfig,
834
- mergedConfig,
866
+ resolvedTarget,
835
867
  linkageSource,
868
+ replacedLegacy,
836
869
  };
837
870
  }
838
871
  async function main() {
@@ -894,8 +927,8 @@ async function main() {
894
927
  const parsed = parseRemoteArgs(remoteArgs);
895
928
  try {
896
929
  if (subcommand === "init") {
897
- const { identity, configPath } = await resolveRemoteContext(parsed);
898
- const endpoint = parsed.endpoint ?? process.env.PASEO_ENDPOINT;
930
+ const { identity, workspaceId, configPath, savedConfig, replacedLegacy } = await resolveRemoteContext(parsed, { tolerateLegacyV1Config: true });
931
+ const endpoint = parsed.endpoint ?? process.env.PASEO_ENDPOINT ?? savedConfig?.endpoint;
899
932
  if (!endpoint) {
900
933
  throw new Error("Remote init requires --endpoint or PASEO_ENDPOINT (optionally loaded from .env)");
901
934
  }
@@ -905,6 +938,7 @@ async function main() {
905
938
  actorId: parsed.actorId ?? process.env.PASEO_ACTOR_ID,
906
939
  provision: parsed.provision,
907
940
  identity,
941
+ existingConfig: savedConfig,
908
942
  });
909
943
  const gitignore = await ensureGitignoreEntries(parsed.repoRoot, [".reffy/state/"]);
910
944
  const payload = {
@@ -914,13 +948,15 @@ async function main() {
914
948
  provider: result.config.provider,
915
949
  config_path: configPath,
916
950
  endpoint: result.config.endpoint,
917
- pod_name: result.config.pod_name,
918
- actor_id: result.config.actor_id,
951
+ workspace_id: workspaceId,
952
+ pod_name: result.target.pod_name,
953
+ actor_id: result.target.actor_id,
919
954
  created_pod: result.created_pod,
920
955
  created_actor: result.created_actor,
921
956
  project_id: identity.project_id,
922
- workspace_name: identity.workspace_name,
923
957
  linkage_mode: result.created_pod || result.created_actor ? "provisioned" : "existing",
958
+ configured_workspace_ids: Object.keys(result.config.targets).sort(),
959
+ replaced_legacy_v1_config: replacedLegacy,
924
960
  gitignore_path: gitignore.path,
925
961
  gitignore_added: gitignore.added,
926
962
  };
@@ -928,16 +964,20 @@ async function main() {
928
964
  printResult(output, payload);
929
965
  }
930
966
  else {
967
+ if (replacedLegacy) {
968
+ console.log("Replacing legacy v1 remote linkage with v2 target map");
969
+ }
931
970
  console.log("Remote linkage ready");
932
971
  console.log(`Config: ${payload.config_path}`);
933
972
  console.log(`Endpoint: ${payload.endpoint}`);
973
+ console.log(`Workspace id: ${payload.workspace_id}`);
934
974
  console.log(`Pod: ${payload.pod_name}`);
935
975
  console.log(`Actor: ${payload.actor_id}`);
936
976
  console.log(`Project identity: ${payload.project_id}`);
937
- console.log(`Workspace name: ${payload.workspace_name}`);
938
977
  console.log(`Created pod: ${payload.created_pod ? "yes" : "no"}`);
939
978
  console.log(`Created actor: ${payload.created_actor ? "yes" : "no"}`);
940
979
  console.log(`Linkage mode: ${payload.linkage_mode}`);
980
+ console.log(`Configured workspace ids: ${payload.configured_workspace_ids.join(", ")}`);
941
981
  if (payload.gitignore_added.length > 0) {
942
982
  console.log(`Updated .gitignore: ${payload.gitignore_added.join(", ")}`);
943
983
  }
@@ -945,18 +985,24 @@ async function main() {
945
985
  return 0;
946
986
  }
947
987
  const context = await resolveRemoteContext(parsed);
948
- if (!context.mergedConfig) {
949
- throw new Error(`Remote linkage is not configured. Run \`reffy remote init\` or provide endpoint, pod name, and actor id. Expected config path: ${context.configPath}`);
988
+ if (!context.resolvedTarget) {
989
+ throw new Error(`Remote linkage is not configured for workspace_id "${context.workspaceId}". Run \`reffy remote init --workspace-id ${context.workspaceId}\` or provide endpoint, pod name, and actor id. Expected config path: ${context.configPath}`);
950
990
  }
951
- const client = new PaseoRemoteClient(context.mergedConfig);
991
+ const client = new PaseoRemoteClient(context.resolvedTarget);
992
+ const linkageLabel = describeRemoteLinkage({
993
+ endpoint: context.resolvedTarget.endpoint,
994
+ pod_name: context.resolvedTarget.pod_name,
995
+ actor_id: context.resolvedTarget.actor_id,
996
+ workspace_id: context.workspaceId,
997
+ });
952
998
  if (subcommand === "status") {
953
999
  let workspace;
954
1000
  try {
955
- workspace = await client.getWorkspace();
1001
+ workspace = await client.getWorkspace(context.workspaceId);
956
1002
  }
957
1003
  catch (error) {
958
1004
  const message = error instanceof Error ? error.message : String(error);
959
- throw new Error(`Remote status failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1005
+ throw new Error(`Remote status failed using ${linkageLabel}: ${message}`);
960
1006
  }
961
1007
  assertRemoteIdentity(workspace, context.identity);
962
1008
  const remoteIdentity = extractWorkspaceIdentity(workspace);
@@ -965,18 +1011,21 @@ async function main() {
965
1011
  command: "remote",
966
1012
  subcommand: "status",
967
1013
  config_path: context.configPath,
968
- provider: context.mergedConfig.provider,
969
- endpoint: context.mergedConfig.endpoint,
970
- pod_name: context.mergedConfig.pod_name,
971
- actor_id: context.mergedConfig.actor_id,
1014
+ provider: "paseo",
1015
+ endpoint: context.resolvedTarget.endpoint,
1016
+ workspace_id: context.workspaceId,
1017
+ pod_name: context.resolvedTarget.pod_name,
1018
+ actor_id: context.resolvedTarget.actor_id,
972
1019
  linkage_source: context.linkageSource,
973
1020
  local_identity: context.identity,
974
1021
  remote_identity: {
975
1022
  project_id: remoteIdentity.project_id,
976
- workspace_name: remoteIdentity.workspace_name,
1023
+ workspace_id: remoteIdentity.workspace_id,
1024
+ actor_type: remoteIdentity.actor_type,
1025
+ backend_version: remoteIdentity.backend_version,
977
1026
  },
978
1027
  document_count: remoteIdentity.document_count,
979
- last_imported_at: context.savedConfig?.last_imported_at ?? null,
1028
+ last_imported_at: context.resolvedTarget.last_imported_at ?? null,
980
1029
  workspace,
981
1030
  };
982
1031
  if (output === "json") {
@@ -986,11 +1035,12 @@ async function main() {
986
1035
  console.log("Remote workspace reachable");
987
1036
  console.log(`Config: ${payload.config_path}`);
988
1037
  console.log(`Endpoint: ${payload.endpoint}`);
1038
+ console.log(`Workspace id: ${payload.workspace_id}`);
989
1039
  console.log(`Pod: ${payload.pod_name}`);
990
1040
  console.log(`Actor: ${payload.actor_id}`);
991
1041
  console.log(`Linkage source: ${payload.linkage_source}`);
992
- console.log(`Local identity: ${payload.local_identity.project_id}/${payload.local_identity.workspace_name}`);
993
- console.log(`Remote identity: ${String(payload.remote_identity.project_id)}/${String(payload.remote_identity.workspace_name)}`);
1042
+ console.log(`Local identity: ${payload.local_identity.project_id}/${payload.local_identity.workspace_id}`);
1043
+ console.log(`Remote identity: ${String(payload.remote_identity.project_id)}/${String(payload.remote_identity.workspace_id)} (${String(payload.remote_identity.actor_type)} ${String(payload.remote_identity.backend_version)})`);
994
1044
  console.log(`Remote document count: ${String(payload.document_count ?? "unknown")}`);
995
1045
  console.log(`Last imported at: ${String(payload.last_imported_at ?? "never")}`);
996
1046
  }
@@ -1000,33 +1050,35 @@ async function main() {
1000
1050
  const documents = await collectWorkspaceDocuments(parsed.repoRoot);
1001
1051
  let importResult;
1002
1052
  try {
1003
- importResult = validateImportResult(await client.importWorkspace(documents, true));
1053
+ importResult = validateImportResult(await client.importWorkspace(context.workspaceId, documents, true));
1004
1054
  }
1005
1055
  catch (error) {
1006
1056
  const message = error instanceof Error ? error.message : String(error);
1007
- throw new Error(`Remote push failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1057
+ throw new Error(`Remote push failed using ${linkageLabel}: ${message}`);
1008
1058
  }
1009
1059
  let workspace;
1010
1060
  try {
1011
- workspace = await client.getWorkspace();
1061
+ workspace = await client.getWorkspace(context.workspaceId);
1012
1062
  }
1013
1063
  catch (error) {
1014
1064
  const message = error instanceof Error ? error.message : String(error);
1015
- throw new Error(`Remote push could not verify remote state using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1065
+ throw new Error(`Remote push could not verify remote state using ${linkageLabel}: ${message}`);
1016
1066
  }
1017
1067
  assertRemoteIdentity(workspace, context.identity);
1018
1068
  const importedAt = new Date().toISOString();
1019
- await updateRemoteConfigMetadata(parsed.repoRoot, { last_imported_at: importedAt });
1069
+ await updateRemoteConfigMetadata(parsed.repoRoot, context.workspaceId, {
1070
+ last_imported_at: importedAt,
1071
+ });
1020
1072
  const payload = {
1021
1073
  status: "ok",
1022
1074
  command: "remote",
1023
1075
  subcommand: "push",
1024
- endpoint: context.mergedConfig.endpoint,
1025
- pod_name: context.mergedConfig.pod_name,
1026
- actor_id: context.mergedConfig.actor_id,
1076
+ endpoint: context.resolvedTarget.endpoint,
1077
+ workspace_id: context.workspaceId,
1078
+ pod_name: context.resolvedTarget.pod_name,
1079
+ actor_id: context.resolvedTarget.actor_id,
1027
1080
  linkage_source: context.linkageSource,
1028
1081
  project_id: context.identity.project_id,
1029
- workspace_name: context.identity.workspace_name,
1030
1082
  local_document_count: documents.length,
1031
1083
  imported: importResult.imported,
1032
1084
  created: importResult.created,
@@ -1042,6 +1094,7 @@ async function main() {
1042
1094
  else {
1043
1095
  console.log("Remote push complete");
1044
1096
  console.log(`Endpoint: ${payload.endpoint}`);
1097
+ console.log(`Workspace id: ${payload.workspace_id}`);
1045
1098
  console.log(`Pod: ${payload.pod_name}`);
1046
1099
  console.log(`Actor: ${payload.actor_id}`);
1047
1100
  console.log(`Linkage source: ${payload.linkage_source}`);
@@ -1059,11 +1112,11 @@ async function main() {
1059
1112
  if (subcommand === "ls") {
1060
1113
  let snapshot;
1061
1114
  try {
1062
- snapshot = await client.getSnapshot(parsed.prefix);
1115
+ snapshot = await client.getSnapshot(context.workspaceId, parsed.prefix);
1063
1116
  }
1064
1117
  catch (error) {
1065
1118
  const message = error instanceof Error ? error.message : String(error);
1066
- throw new Error(`Remote ls failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1119
+ throw new Error(`Remote ls failed using ${linkageLabel}: ${message}`);
1067
1120
  }
1068
1121
  const paths = (snapshot.documents ?? [])
1069
1122
  .map((document) => (typeof document.path === "string" ? document.path : ""))
@@ -1073,9 +1126,10 @@ async function main() {
1073
1126
  status: "ok",
1074
1127
  command: "remote",
1075
1128
  subcommand: "ls",
1076
- endpoint: context.mergedConfig.endpoint,
1077
- pod_name: context.mergedConfig.pod_name,
1078
- actor_id: context.mergedConfig.actor_id,
1129
+ endpoint: context.resolvedTarget.endpoint,
1130
+ workspace_id: context.workspaceId,
1131
+ pod_name: context.resolvedTarget.pod_name,
1132
+ actor_id: context.resolvedTarget.actor_id,
1079
1133
  linkage_source: context.linkageSource,
1080
1134
  prefix: parsed.prefix ?? null,
1081
1135
  paths,
@@ -1103,22 +1157,23 @@ async function main() {
1103
1157
  }
1104
1158
  let document;
1105
1159
  try {
1106
- document = await client.getDocument(documentPath);
1160
+ document = await client.getDocument(context.workspaceId, documentPath);
1107
1161
  }
1108
1162
  catch (error) {
1109
1163
  const message = error instanceof Error ? error.message : String(error);
1110
- throw new Error(`Remote cat failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1164
+ throw new Error(`Remote cat failed using ${linkageLabel}: ${message}`);
1111
1165
  }
1112
1166
  if (!document.document) {
1113
- throw new Error(`Remote document not found using ${describeRemoteLinkage(context.mergedConfig)}: ${documentPath}`);
1167
+ throw new Error(`Remote document not found using ${linkageLabel}: ${documentPath}`);
1114
1168
  }
1115
1169
  const payload = {
1116
1170
  status: "ok",
1117
1171
  command: "remote",
1118
1172
  subcommand: "cat",
1119
- endpoint: context.mergedConfig.endpoint,
1120
- pod_name: context.mergedConfig.pod_name,
1121
- actor_id: context.mergedConfig.actor_id,
1173
+ endpoint: context.resolvedTarget.endpoint,
1174
+ workspace_id: context.workspaceId,
1175
+ pod_name: context.resolvedTarget.pod_name,
1176
+ actor_id: context.resolvedTarget.actor_id,
1122
1177
  linkage_source: context.linkageSource,
1123
1178
  document: document.document,
1124
1179
  };
package/dist/index.d.ts CHANGED
@@ -4,5 +4,5 @@ export { runDoctor } from "./doctor.js";
4
4
  export { createPlanScaffold } from "./plan.js";
5
5
  export { loadDotEnvIfPresent, parseDotEnv } from "./env.js";
6
6
  export { ensureGitignoreEntries } from "./gitignore.js";
7
- export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
7
+ export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, resolveRemoteTarget, resolveSelectedWorkspaceId, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
8
8
  export { listSpecs, showSpec } from "./spec-runtime.js";
package/dist/index.js CHANGED
@@ -4,5 +4,5 @@ export { runDoctor } from "./doctor.js";
4
4
  export { createPlanScaffold } from "./plan.js";
5
5
  export { loadDotEnvIfPresent, parseDotEnv } from "./env.js";
6
6
  export { ensureGitignoreEntries } from "./gitignore.js";
7
- export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
7
+ export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, resolveRemoteTarget, resolveSelectedWorkspaceId, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
8
8
  export { listSpecs, showSpec } from "./spec-runtime.js";
@@ -1,7 +1,11 @@
1
1
  import type { Manifest } from "./types.js";
2
2
  export declare const MANIFEST_VERSION = 1;
3
3
  export declare function allowedKindExtensions(): Record<string, string[]>;
4
- export declare function deriveManifestIdentity(repoRoot: string): Required<Pick<Manifest, "project_id" | "workspace_name">>;
4
+ export interface ManifestIdentityDefaults {
5
+ project_id: string;
6
+ workspace_ids: string[];
7
+ }
8
+ export declare function deriveManifestIdentity(repoRoot: string): ManifestIdentityDefaults;
5
9
  export declare function createManifest(repoRoot: string, now?: string): Manifest;
6
10
  export declare function normalizeManifest(raw: unknown, repoRoot: string): Manifest;
7
11
  export declare function inferArtifactType(filePath: string): {
package/dist/manifest.js CHANGED
@@ -13,6 +13,7 @@ const KIND_EXTENSIONS = {
13
13
  file: [],
14
14
  };
15
15
  const PROJECT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const WORKSPACE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
17
  const FALLBACK_WORKSPACE_ID = "reffy-workspace";
17
18
  function isObject(value) {
18
19
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -25,11 +26,22 @@ function isIsoDate(value) {
25
26
  function isProjectId(value) {
26
27
  return typeof value === "string" && PROJECT_ID_PATTERN.test(value);
27
28
  }
29
+ function isWorkspaceId(value) {
30
+ return typeof value === "string" && WORKSPACE_ID_PATTERN.test(value);
31
+ }
28
32
  function isWorkspaceName(value) {
29
33
  return (typeof value === "string" &&
30
34
  value.trim().length > 0 &&
31
35
  !/[\\/\r\n\t]/.test(value));
32
36
  }
37
+ function slugifyWorkspaceId(value) {
38
+ const slug = value
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, "-")
42
+ .replace(/^-+|-+$/g, "");
43
+ return slug.length > 0 ? slug : null;
44
+ }
33
45
  function slugifyIdentity(value) {
34
46
  return value
35
47
  .trim()
@@ -51,18 +63,33 @@ export function deriveManifestIdentity(repoRoot) {
51
63
  const identity = candidate || FALLBACK_WORKSPACE_ID;
52
64
  return {
53
65
  project_id: identity,
54
- workspace_name: identity,
66
+ workspace_ids: [identity],
55
67
  };
56
68
  }
57
69
  export function createManifest(repoRoot, now = new Date().toISOString()) {
70
+ const defaults = deriveManifestIdentity(repoRoot);
58
71
  return {
59
72
  version: MANIFEST_VERSION,
60
73
  created_at: now,
61
74
  updated_at: now,
62
- ...deriveManifestIdentity(repoRoot),
75
+ project_id: defaults.project_id,
76
+ workspace_ids: defaults.workspace_ids,
63
77
  artifacts: [],
64
78
  };
65
79
  }
80
+ function normalizeWorkspaceIds(raw, fallback) {
81
+ const explicit = Array.isArray(raw.workspace_ids)
82
+ ? raw.workspace_ids.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
83
+ : null;
84
+ if (explicit && explicit.length > 0) {
85
+ return Array.from(new Set(explicit.map((entry) => entry.trim())));
86
+ }
87
+ if (typeof raw.workspace_name === "string" && raw.workspace_name.trim().length > 0) {
88
+ const migrated = slugifyWorkspaceId(raw.workspace_name) ?? raw.workspace_name.trim();
89
+ return [migrated];
90
+ }
91
+ return [...fallback];
92
+ }
66
93
  export function normalizeManifest(raw, repoRoot) {
67
94
  const now = new Date().toISOString();
68
95
  const defaults = deriveManifestIdentity(repoRoot);
@@ -71,7 +98,8 @@ export function normalizeManifest(raw, repoRoot) {
71
98
  version: 0,
72
99
  created_at: now,
73
100
  updated_at: now,
74
- ...defaults,
101
+ project_id: defaults.project_id,
102
+ workspace_ids: defaults.workspace_ids,
75
103
  artifacts: raw,
76
104
  };
77
105
  }
@@ -79,12 +107,13 @@ export function normalizeManifest(raw, repoRoot) {
79
107
  return createManifest(repoRoot, now);
80
108
  }
81
109
  const artifacts = Array.isArray(raw.artifacts) ? raw.artifacts : [];
110
+ const workspaceIds = normalizeWorkspaceIds(raw, defaults.workspace_ids);
82
111
  return {
83
112
  version: typeof raw.version === "number" ? raw.version : MANIFEST_VERSION,
84
113
  created_at: typeof raw.created_at === "string" ? raw.created_at : now,
85
114
  updated_at: typeof raw.updated_at === "string" ? raw.updated_at : now,
86
115
  project_id: typeof raw.project_id === "string" ? raw.project_id : defaults.project_id,
87
- workspace_name: typeof raw.workspace_name === "string" ? raw.workspace_name : defaults.workspace_name,
116
+ workspace_ids: workspaceIds,
88
117
  artifacts,
89
118
  };
90
119
  }
@@ -183,8 +212,32 @@ export async function validateManifest(manifestPath, artifactsDir) {
183
212
  if (raw.project_id !== undefined && !isProjectId(raw.project_id)) {
184
213
  errors.push("project_id must be a non-empty kebab-case string");
185
214
  }
186
- if (raw.workspace_name !== undefined && !isWorkspaceName(raw.workspace_name)) {
187
- errors.push("workspace_name must be a non-empty string without path separators or control characters");
215
+ if (raw.workspace_ids !== undefined) {
216
+ if (!Array.isArray(raw.workspace_ids) || raw.workspace_ids.length === 0) {
217
+ errors.push("workspace_ids must be a non-empty array of kebab-case strings when provided");
218
+ }
219
+ else {
220
+ const seen = new Set();
221
+ raw.workspace_ids.forEach((entry, index) => {
222
+ if (!isWorkspaceId(entry)) {
223
+ errors.push(`workspace_ids[${index}] must be a non-empty kebab-case string`);
224
+ return;
225
+ }
226
+ if (seen.has(entry)) {
227
+ errors.push(`workspace_ids[${index}] duplicates "${entry}"`);
228
+ return;
229
+ }
230
+ seen.add(entry);
231
+ });
232
+ }
233
+ }
234
+ if (raw.workspace_name !== undefined) {
235
+ if (!isWorkspaceName(raw.workspace_name)) {
236
+ errors.push("workspace_name must be a non-empty string without path separators or control characters");
237
+ }
238
+ else if (raw.workspace_ids === undefined) {
239
+ warnings.push("workspace_name is deprecated; migrate to workspace_ids");
240
+ }
188
241
  }
189
242
  if (!Array.isArray(raw.artifacts)) {
190
243
  errors.push("artifacts must be an array");
package/dist/remote.d.ts CHANGED
@@ -1,9 +1,21 @@
1
- import type { RemoteDocument, RemoteImportResult, RemoteLinkConfig, RemoteWorkspaceSummary } from "./types.js";
2
- export declare const REMOTE_CONFIG_VERSION = 1;
1
+ import type { RemoteDocument, RemoteImportResult, RemoteLinkConfig, RemoteTarget, RemoteWorkspaceSummary } from "./types.js";
2
+ export declare const REMOTE_CONFIG_VERSION = 2;
3
3
  export declare const REMOTE_PROVIDER = "paseo";
4
+ export declare const REMOTE_BACKEND_ACTOR_TYPE = "reffyRemoteBackend";
5
+ export declare const REMOTE_BACKEND_VERSION = "v2";
4
6
  export interface WorkspaceIdentity {
5
7
  project_id: string;
6
- workspace_name: string;
8
+ workspace_id: string;
9
+ }
10
+ export interface ManifestIdentityLike {
11
+ project_id?: string;
12
+ workspace_ids?: string[];
13
+ }
14
+ export interface ResolvedRemoteTarget {
15
+ endpoint: string;
16
+ pod_name: string;
17
+ actor_id: string;
18
+ last_imported_at?: string;
7
19
  }
8
20
  export interface EnsureRemoteInitOptions {
9
21
  endpoint: string;
@@ -11,9 +23,11 @@ export interface EnsureRemoteInitOptions {
11
23
  actorId?: string;
12
24
  provision: boolean;
13
25
  identity: WorkspaceIdentity;
26
+ existingConfig: RemoteLinkConfig | null;
14
27
  }
15
28
  export interface EnsureRemoteInitResult {
16
29
  config: RemoteLinkConfig;
30
+ target: RemoteTarget;
17
31
  created_pod: boolean;
18
32
  created_actor: boolean;
19
33
  }
@@ -24,24 +38,39 @@ export interface ValidatedImportResult {
24
38
  deleted: number;
25
39
  }
26
40
  export declare function resolveRemoteConfigPath(repoRoot: string): string;
41
+ export declare function resolveSelectedWorkspaceId(identity: ManifestIdentityLike, override: string | undefined): string;
42
+ export declare function requireWorkspaceIdentity(identity: ManifestIdentityLike, selectedWorkspaceId: string): WorkspaceIdentity;
27
43
  export declare function readRemoteConfig(repoRoot: string): Promise<RemoteLinkConfig | null>;
28
44
  export declare function writeRemoteConfig(repoRoot: string, config: RemoteLinkConfig): Promise<string>;
29
- export declare function updateRemoteConfigMetadata(repoRoot: string, patch: Partial<Pick<RemoteLinkConfig, "last_imported_at">>): Promise<RemoteLinkConfig | null>;
30
- export declare function mergeRemoteConfig(stored: RemoteLinkConfig | null, overrides: Partial<Pick<RemoteLinkConfig, "endpoint" | "pod_name" | "actor_id">>): RemoteLinkConfig | null;
31
- export declare function describeRemoteLinkage(config: Pick<RemoteLinkConfig, "endpoint" | "pod_name" | "actor_id">): string;
45
+ export declare function updateRemoteConfigMetadata(repoRoot: string, workspaceId: string, patch: Partial<Pick<RemoteTarget, "last_imported_at">>): Promise<RemoteLinkConfig | null>;
46
+ export declare function resolveRemoteTarget(stored: RemoteLinkConfig | null, workspaceId: string, overrides: {
47
+ endpoint?: string;
48
+ pod_name?: string;
49
+ actor_id?: string;
50
+ }): ResolvedRemoteTarget | null;
51
+ export declare function describeRemoteLinkage(linkage: {
52
+ endpoint: string;
53
+ pod_name: string;
54
+ actor_id: string;
55
+ workspace_id: string;
56
+ }): string;
32
57
  export declare class PaseoRemoteClient {
33
58
  private readonly config;
34
- constructor(config: Pick<RemoteLinkConfig, "endpoint" | "pod_name" | "actor_id">);
59
+ constructor(config: {
60
+ endpoint: string;
61
+ pod_name: string;
62
+ actor_id: string;
63
+ });
35
64
  createPod(): Promise<string>;
36
65
  createActor(identity: WorkspaceIdentity): Promise<string>;
37
- getWorkspace(): Promise<RemoteWorkspaceSummary>;
38
- importWorkspace(documents: RemoteDocument[], replaceMissing?: boolean): Promise<RemoteImportResult>;
39
- getSnapshot(prefix?: string): Promise<{
66
+ getWorkspace(workspaceId: string): Promise<RemoteWorkspaceSummary>;
67
+ importWorkspace(workspaceId: string, documents: RemoteDocument[], replaceMissing?: boolean): Promise<RemoteImportResult>;
68
+ getSnapshot(workspaceId: string, prefix?: string): Promise<{
40
69
  documents?: Array<{
41
70
  path?: string;
42
71
  }>;
43
72
  }>;
44
- getDocument(documentPath: string): Promise<{
73
+ getDocument(workspaceId: string, documentPath: string): Promise<{
45
74
  document?: {
46
75
  path?: string;
47
76
  content?: string;
@@ -50,13 +79,11 @@ export declare class PaseoRemoteClient {
50
79
  }>;
51
80
  }
52
81
  export declare function ensureRemoteInit(repoRoot: string, options: EnsureRemoteInitOptions): Promise<EnsureRemoteInitResult>;
53
- export declare function requireWorkspaceIdentity(identity: {
54
- project_id?: string;
55
- workspace_name?: string;
56
- }): WorkspaceIdentity;
57
82
  export declare function extractWorkspaceIdentity(summary: RemoteWorkspaceSummary): {
58
83
  project_id?: string | null;
59
- workspace_name?: string | null;
84
+ workspace_id?: string | null;
85
+ actor_type?: string | null;
86
+ backend_version?: string | null;
60
87
  document_count?: number | null;
61
88
  };
62
89
  export declare function assertRemoteIdentity(summary: RemoteWorkspaceSummary, identity: WorkspaceIdentity): void;
package/dist/remote.js CHANGED
@@ -1,8 +1,10 @@
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 = 2;
5
5
  export const REMOTE_PROVIDER = "paseo";
6
+ export const REMOTE_BACKEND_ACTOR_TYPE = "reffyRemoteBackend";
7
+ export const REMOTE_BACKEND_VERSION = "v2";
6
8
  const REMOTE_STATE_DIR = path.join("state");
7
9
  const REMOTE_CONFIG_FILE = "remote.json";
8
10
  function isObject(value) {
@@ -17,6 +19,39 @@ function normalizeSeparators(value) {
17
19
  export function resolveRemoteConfigPath(repoRoot) {
18
20
  return path.join(resolveRefsDir(repoRoot), REMOTE_STATE_DIR, REMOTE_CONFIG_FILE);
19
21
  }
22
+ export function resolveSelectedWorkspaceId(identity, override) {
23
+ const workspaceIds = (identity.workspace_ids ?? [])
24
+ .map((entry) => entry.trim())
25
+ .filter((entry) => entry.length > 0);
26
+ if (override !== undefined) {
27
+ const trimmed = override.trim();
28
+ if (!trimmed) {
29
+ throw new Error("--workspace-id requires a non-empty value");
30
+ }
31
+ if (workspaceIds.length > 0 && !workspaceIds.includes(trimmed)) {
32
+ throw new Error(`Selected workspace id "${trimmed}" is not a member of manifest.workspace_ids: ${workspaceIds.join(", ") || "(none)"}`);
33
+ }
34
+ return trimmed;
35
+ }
36
+ if (workspaceIds.length === 0) {
37
+ throw new Error("Remote sync requires .reffy/manifest.json to define workspace_ids. Run `reffy init` or add at least one workspace id.");
38
+ }
39
+ if (workspaceIds.length === 1) {
40
+ return workspaceIds[0];
41
+ }
42
+ throw new Error(`Manifest has multiple workspace_ids (${workspaceIds.join(", ")}); pass --workspace-id to select a target projection.`);
43
+ }
44
+ export function requireWorkspaceIdentity(identity, selectedWorkspaceId) {
45
+ const project_id = identity.project_id?.trim();
46
+ if (!project_id) {
47
+ throw new Error("Remote sync requires .reffy/manifest.json to define project_id");
48
+ }
49
+ const workspace_id = selectedWorkspaceId.trim();
50
+ if (!workspace_id) {
51
+ throw new Error("Remote sync requires a selected workspace_id");
52
+ }
53
+ return { project_id, workspace_id };
54
+ }
20
55
  export async function readRemoteConfig(repoRoot) {
21
56
  const configPath = resolveRemoteConfigPath(repoRoot);
22
57
  const raw = await fs.readFile(configPath, "utf8").catch(() => null);
@@ -32,28 +67,43 @@ export async function readRemoteConfig(repoRoot) {
32
67
  if (!isObject(parsed)) {
33
68
  throw new Error(`Remote config at ${configPath} must be an object`);
34
69
  }
35
- if (parsed.version !== REMOTE_CONFIG_VERSION) {
36
- throw new Error(`Remote config at ${configPath} must use version ${String(REMOTE_CONFIG_VERSION)}`);
37
- }
38
70
  if (parsed.provider !== REMOTE_PROVIDER) {
39
71
  throw new Error(`Remote config at ${configPath} must use provider "${REMOTE_PROVIDER}"`);
40
72
  }
41
73
  if (!isNonEmptyString(parsed.endpoint)) {
42
74
  throw new Error(`Remote config at ${configPath} must define endpoint`);
43
75
  }
44
- if (!isNonEmptyString(parsed.pod_name)) {
45
- throw new Error(`Remote config at ${configPath} must define pod_name`);
76
+ 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\`.`);
46
78
  }
47
- if (!isNonEmptyString(parsed.actor_id)) {
48
- throw new Error(`Remote config at ${configPath} must define actor_id`);
79
+ if (parsed.version !== REMOTE_CONFIG_VERSION) {
80
+ throw new Error(`Remote config at ${configPath} must use version ${String(REMOTE_CONFIG_VERSION)}`);
81
+ }
82
+ if (!isObject(parsed.targets)) {
83
+ throw new Error(`Remote config at ${configPath} must define targets as an object keyed by workspace_id`);
84
+ }
85
+ const targets = {};
86
+ for (const [workspaceId, entry] of Object.entries(parsed.targets)) {
87
+ if (!isObject(entry)) {
88
+ throw new Error(`Remote config at ${configPath} has invalid target for workspace_id "${workspaceId}"`);
89
+ }
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
+ }
96
+ targets[workspaceId] = {
97
+ pod_name: entry.pod_name.trim(),
98
+ actor_id: entry.actor_id.trim(),
99
+ last_imported_at: isNonEmptyString(entry.last_imported_at) ? entry.last_imported_at.trim() : undefined,
100
+ };
49
101
  }
50
102
  return {
51
103
  version: REMOTE_CONFIG_VERSION,
52
104
  provider: REMOTE_PROVIDER,
53
105
  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,
106
+ targets,
57
107
  };
58
108
  }
59
109
  export async function writeRemoteConfig(repoRoot, config) {
@@ -62,38 +112,40 @@ export async function writeRemoteConfig(repoRoot, config) {
62
112
  await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
63
113
  return configPath;
64
114
  }
65
- export async function updateRemoteConfigMetadata(repoRoot, patch) {
115
+ export async function updateRemoteConfigMetadata(repoRoot, workspaceId, patch) {
66
116
  const existing = await readRemoteConfig(repoRoot);
67
117
  if (!existing)
68
118
  return null;
119
+ const target = existing.targets[workspaceId];
120
+ if (!target)
121
+ return null;
69
122
  const next = {
70
123
  ...existing,
71
- ...patch,
124
+ targets: {
125
+ ...existing.targets,
126
+ [workspaceId]: { ...target, ...patch },
127
+ },
72
128
  };
73
129
  await writeRemoteConfig(repoRoot, next);
74
130
  return next;
75
131
  }
76
- export function mergeRemoteConfig(stored, overrides) {
77
- if (!stored && !overrides.endpoint && !overrides.pod_name && !overrides.actor_id) {
78
- return null;
79
- }
132
+ export function resolveRemoteTarget(stored, workspaceId, overrides) {
80
133
  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;
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;
83
137
  if (!endpoint || !pod_name || !actor_id) {
84
138
  return null;
85
139
  }
86
140
  return {
87
- version: REMOTE_CONFIG_VERSION,
88
- provider: REMOTE_PROVIDER,
89
141
  endpoint,
90
142
  pod_name,
91
143
  actor_id,
92
- last_imported_at: stored?.last_imported_at,
144
+ last_imported_at: target?.last_imported_at,
93
145
  };
94
146
  }
95
- export function describeRemoteLinkage(config) {
96
- return `endpoint=${config.endpoint} pod=${config.pod_name} actor=${config.actor_id}`;
147
+ export function describeRemoteLinkage(linkage) {
148
+ return `endpoint=${linkage.endpoint} pod=${linkage.pod_name} actor=${linkage.actor_id} workspace=${linkage.workspace_id}`;
97
149
  }
98
150
  async function http(url, init = {}) {
99
151
  const headers = new Headers(init.headers ?? {});
@@ -114,6 +166,9 @@ async function httpJson(url, init = {}) {
114
166
  function actorBase(config) {
115
167
  return `${config.endpoint}/pods/${config.pod_name}/actors/${config.actor_id}`;
116
168
  }
169
+ function workspaceBase(config, workspaceId) {
170
+ return `${actorBase(config)}/workspaces/${encodeURIComponent(workspaceId)}`;
171
+ }
117
172
  export class PaseoRemoteClient {
118
173
  config;
119
174
  constructor(config) {
@@ -131,12 +186,12 @@ export class PaseoRemoteClient {
131
186
  method: "POST",
132
187
  body: JSON.stringify({
133
188
  config: {
134
- actorType: "reffyRemoteBackend",
135
- version: "v1",
189
+ actorType: REMOTE_BACKEND_ACTOR_TYPE,
190
+ version: REMOTE_BACKEND_VERSION,
136
191
  schema: { type: "object" },
137
192
  params: {
138
193
  project_id: identity.project_id,
139
- workspace_name: identity.workspace_name,
194
+ workspace_ids: [identity.workspace_id],
140
195
  default_lock_ttl_seconds: 120,
141
196
  max_snapshot_documents: 1000,
142
197
  },
@@ -148,11 +203,11 @@ export class PaseoRemoteClient {
148
203
  }
149
204
  return result.actorId.trim();
150
205
  }
151
- async getWorkspace() {
152
- return await httpJson(`${actorBase(this.config)}/workspace`);
206
+ async getWorkspace(workspaceId) {
207
+ return await httpJson(workspaceBase(this.config, workspaceId));
153
208
  }
154
- async importWorkspace(documents, replaceMissing = true) {
155
- return await httpJson(`${actorBase(this.config)}/workspace/import`, {
209
+ async importWorkspace(workspaceId, documents, replaceMissing = true) {
210
+ return await httpJson(`${workspaceBase(this.config, workspaceId)}/import`, {
156
211
  method: "POST",
157
212
  body: JSON.stringify({
158
213
  documents,
@@ -160,27 +215,29 @@ export class PaseoRemoteClient {
160
215
  }),
161
216
  });
162
217
  }
163
- async getSnapshot(prefix) {
218
+ async getSnapshot(workspaceId, prefix) {
164
219
  const query = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
165
- return await httpJson(`${actorBase(this.config)}/workspace/snapshot${query}`);
220
+ return await httpJson(`${workspaceBase(this.config, workspaceId)}/snapshot${query}`);
166
221
  }
167
- async getDocument(documentPath) {
168
- return await httpJson(`${actorBase(this.config)}/workspace/documents?path=${encodeURIComponent(documentPath)}`);
222
+ async getDocument(workspaceId, documentPath) {
223
+ return await httpJson(`${workspaceBase(this.config, workspaceId)}/documents?path=${encodeURIComponent(documentPath)}`);
169
224
  }
170
225
  }
171
226
  export async function ensureRemoteInit(repoRoot, options) {
172
227
  let created_pod = false;
173
228
  let created_actor = false;
174
- let podName = options.podName?.trim() || "";
175
- let actorId = options.actorId?.trim() || "";
176
- if (!options.endpoint.trim()) {
229
+ const endpoint = options.endpoint.trim();
230
+ if (!endpoint) {
177
231
  throw new Error("Remote init requires an endpoint");
178
232
  }
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 || "";
179
236
  if (!podName && !options.provision) {
180
237
  throw new Error("Remote init requires --pod-name unless --provision is set");
181
238
  }
182
239
  const baseClient = new PaseoRemoteClient({
183
- endpoint: options.endpoint.trim(),
240
+ endpoint,
184
241
  pod_name: podName || "pending-pod",
185
242
  actor_id: actorId || "pending-actor",
186
243
  });
@@ -193,44 +250,49 @@ export async function ensureRemoteInit(repoRoot, options) {
193
250
  throw new Error("Remote init requires --actor-id unless --provision is set");
194
251
  }
195
252
  const client = new PaseoRemoteClient({
196
- endpoint: options.endpoint.trim(),
253
+ endpoint,
197
254
  pod_name: podName,
198
255
  actor_id: "pending-actor",
199
256
  });
200
257
  actorId = await client.createActor(options.identity);
201
258
  created_actor = true;
202
259
  }
260
+ const nextTarget = {
261
+ pod_name: podName,
262
+ actor_id: actorId,
263
+ last_imported_at: priorTarget?.last_imported_at,
264
+ };
203
265
  const config = {
204
266
  version: REMOTE_CONFIG_VERSION,
205
267
  provider: REMOTE_PROVIDER,
206
- endpoint: options.endpoint.trim(),
207
- pod_name: podName,
208
- actor_id: actorId,
268
+ endpoint,
269
+ targets: {
270
+ ...(options.existingConfig?.targets ?? {}),
271
+ [options.identity.workspace_id]: nextTarget,
272
+ },
209
273
  };
210
274
  await writeRemoteConfig(repoRoot, config);
211
- return { config, created_pod, created_actor };
212
- }
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");
218
- }
219
- return { project_id, workspace_name };
275
+ return { config, target: nextTarget, created_pod, created_actor };
220
276
  }
221
277
  export function extractWorkspaceIdentity(summary) {
278
+ const source = isObject(summary.source) ? summary.source : {};
222
279
  const workspace = isObject(summary.workspace) ? summary.workspace : {};
223
280
  const stats = isObject(summary.stats) ? summary.stats : {};
224
281
  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,
282
+ project_id: typeof source.project_id === "string" ? source.project_id : null,
283
+ 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,
227
286
  document_count: typeof stats.document_count === "number" ? stats.document_count : null,
228
287
  };
229
288
  }
230
289
  export function assertRemoteIdentity(summary, identity) {
231
290
  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)}`);
291
+ 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`.");
293
+ }
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)}`);
234
296
  }
235
297
  }
236
298
  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,16 +17,21 @@ 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
  }
25
+ export interface RemoteTarget {
26
+ pod_name: string;
27
+ actor_id: string;
28
+ last_imported_at?: string;
29
+ }
23
30
  export interface RemoteLinkConfig {
24
31
  version: number;
25
32
  provider: "paseo";
26
33
  endpoint: string;
27
- pod_name: string;
28
- actor_id: string;
29
- last_imported_at?: string;
34
+ targets: Record<string, RemoteTarget>;
30
35
  }
31
36
  export interface RemoteDocument {
32
37
  path: string;
@@ -41,6 +46,7 @@ export interface RemoteImportResult {
41
46
  deleted?: number;
42
47
  }
43
48
  export interface RemoteWorkspaceSummary {
49
+ source?: Record<string, unknown>;
44
50
  workspace?: Record<string, unknown>;
45
51
  stats?: Record<string, unknown>;
46
52
  }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy-cli",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "Planning system for AI assisted development.",
5
5
  "keywords": [
6
6
  "reffy",
@@ -35,7 +35,6 @@
35
35
  "prepare": "npm run build",
36
36
  "dev": "tsx watch src/cli.ts",
37
37
  "check": "tsc --noEmit",
38
- "remote:backend:demo": "node scripts/reffy-remote-backend-demo.mjs",
39
38
  "test": "npm run build && vitest run --coverage",
40
39
  "test:watch": "vitest"
41
40
  },