reffy-cli 1.4.1 → 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
@@ -5,11 +5,12 @@ import path from "node:path";
5
5
  import { renderDiagram } from "./diagram.js";
6
6
  import { runDoctor } from "./doctor.js";
7
7
  import { loadDotEnvIfPresent } from "./env.js";
8
+ import { ensureGitignoreEntries } from "./gitignore.js";
8
9
  import { archivePlanningChange } from "./plan-archive.js";
9
10
  import { createPlanScaffold } from "./plan.js";
10
11
  import { DEFAULT_PLANNING_RELATIVE_DIR, looksLikePlanningDir, resolveCanonicalPlanningPath } from "./planning-paths.js";
11
12
  import { listPlanningChanges, showPlanningChange, validatePlanningChange } from "./plan-runtime.js";
12
- 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";
13
14
  import { DEFAULT_REFS_DIRNAME, detectWorkspaceState, looksLikeRefsDir } from "./refs-paths.js";
14
15
  import { prepareCanonicalPlanningLayout } from "./planning-workspace.js";
15
16
  import { ReferencesStore } from "./storage.js";
@@ -484,16 +485,17 @@ function planUsage() {
484
485
  function remoteUsage() {
485
486
  return [
486
487
  "Usage:",
487
- " reffy remote init [--repo PATH] [--output text|json] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--provision] [--env-file PATH]",
488
- " reffy remote status [--repo PATH] [--output text|json] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
489
- " reffy remote push [--repo PATH] [--output text|json] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
490
- " reffy remote ls [--repo PATH] [--output text|json] [--prefix PATH] [--endpoint URL] [--pod-name NAME] [--actor-id ID] [--env-file PATH]",
491
- " 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]",
492
493
  "",
493
494
  "Options:",
495
+ " --workspace-id ID Selected workspace projection. Required when the manifest has multiple workspace_ids; otherwise inferred.",
494
496
  " --endpoint URL Paseo base URL. Defaults to saved config or PASEO_ENDPOINT/.env.",
495
- " --pod-name NAME Paseo pod name. Defaults to saved config or PASEO_POD_NAME/.env.",
496
- " --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.",
497
499
  " --provision Create missing pod/actor during `remote init`.",
498
500
  " --prefix PATH Prefix filter for `remote ls`.",
499
501
  " --env-file PATH Dotenv file to load before resolving env-backed options. Defaults to .env.",
@@ -757,6 +759,18 @@ function parseRemoteArgs(argv) {
757
759
  args.prefix = arg.split("=", 2)[1];
758
760
  continue;
759
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
+ }
760
774
  if (arg === "--provision") {
761
775
  args.provision = true;
762
776
  continue;
@@ -777,7 +791,8 @@ function getRemotePositionalArgs(argv) {
777
791
  arg === "--pod-name" ||
778
792
  arg === "--actor-id" ||
779
793
  arg === "--env-file" ||
780
- arg === "--prefix") {
794
+ arg === "--prefix" ||
795
+ arg === "--workspace-id") {
781
796
  i += 1;
782
797
  continue;
783
798
  }
@@ -788,6 +803,7 @@ function getRemotePositionalArgs(argv) {
788
803
  arg.startsWith("--actor-id=") ||
789
804
  arg.startsWith("--env-file=") ||
790
805
  arg.startsWith("--prefix=") ||
806
+ arg.startsWith("--workspace-id=") ||
791
807
  arg === "--json" ||
792
808
  arg === "--provision") {
793
809
  continue;
@@ -808,30 +824,48 @@ function printSection(title, values) {
808
824
  console.log(`- ${value}`);
809
825
  }
810
826
  }
811
- async function resolveRemoteContext(args) {
827
+ async function resolveRemoteContext(args, opts = {}) {
812
828
  await loadDotEnvIfPresent(args.repoRoot, args.envFile ?? ".env");
813
829
  const store = new ReferencesStore(args.repoRoot);
814
- const identity = requireWorkspaceIdentity(await store.getWorkspaceIdentity());
815
- 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
+ }
816
847
  const overrideValues = {
817
848
  endpoint: args.endpoint ?? process.env.PASEO_ENDPOINT,
818
849
  pod_name: args.podName ?? process.env.PASEO_POD_NAME,
819
850
  actor_id: args.actorId ?? process.env.PASEO_ACTOR_ID,
820
851
  };
821
- const mergedConfig = mergeRemoteConfig(savedConfig, overrideValues);
852
+ const resolvedTarget = resolveRemoteTarget(savedConfig, workspaceId, overrideValues);
822
853
  const hasOverrides = Boolean(overrideValues.endpoint || overrideValues.pod_name || overrideValues.actor_id);
823
- const linkageSource = mergedConfig
824
- ? savedConfig && !hasOverrides
854
+ const savedHasTarget = Boolean(savedConfig?.targets?.[workspaceId]);
855
+ const linkageSource = resolvedTarget
856
+ ? savedHasTarget && !hasOverrides
825
857
  ? "saved_config"
826
858
  : "cli_or_env_overrides"
827
859
  : "unconfigured";
828
860
  return {
829
861
  store,
830
862
  identity,
863
+ workspaceId,
831
864
  configPath: resolveRemoteConfigPath(args.repoRoot),
832
865
  savedConfig,
833
- mergedConfig,
866
+ resolvedTarget,
834
867
  linkageSource,
868
+ replacedLegacy,
835
869
  };
836
870
  }
837
871
  async function main() {
@@ -893,8 +927,8 @@ async function main() {
893
927
  const parsed = parseRemoteArgs(remoteArgs);
894
928
  try {
895
929
  if (subcommand === "init") {
896
- const { identity, configPath } = await resolveRemoteContext(parsed);
897
- 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;
898
932
  if (!endpoint) {
899
933
  throw new Error("Remote init requires --endpoint or PASEO_ENDPOINT (optionally loaded from .env)");
900
934
  }
@@ -904,7 +938,9 @@ async function main() {
904
938
  actorId: parsed.actorId ?? process.env.PASEO_ACTOR_ID,
905
939
  provision: parsed.provision,
906
940
  identity,
941
+ existingConfig: savedConfig,
907
942
  });
943
+ const gitignore = await ensureGitignoreEntries(parsed.repoRoot, [".reffy/state/"]);
908
944
  const payload = {
909
945
  status: "ok",
910
946
  command: "remote",
@@ -912,44 +948,61 @@ async function main() {
912
948
  provider: result.config.provider,
913
949
  config_path: configPath,
914
950
  endpoint: result.config.endpoint,
915
- pod_name: result.config.pod_name,
916
- actor_id: result.config.actor_id,
951
+ workspace_id: workspaceId,
952
+ pod_name: result.target.pod_name,
953
+ actor_id: result.target.actor_id,
917
954
  created_pod: result.created_pod,
918
955
  created_actor: result.created_actor,
919
956
  project_id: identity.project_id,
920
- workspace_name: identity.workspace_name,
921
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,
960
+ gitignore_path: gitignore.path,
961
+ gitignore_added: gitignore.added,
922
962
  };
923
963
  if (output === "json") {
924
964
  printResult(output, payload);
925
965
  }
926
966
  else {
967
+ if (replacedLegacy) {
968
+ console.log("Replacing legacy v1 remote linkage with v2 target map");
969
+ }
927
970
  console.log("Remote linkage ready");
928
971
  console.log(`Config: ${payload.config_path}`);
929
972
  console.log(`Endpoint: ${payload.endpoint}`);
973
+ console.log(`Workspace id: ${payload.workspace_id}`);
930
974
  console.log(`Pod: ${payload.pod_name}`);
931
975
  console.log(`Actor: ${payload.actor_id}`);
932
976
  console.log(`Project identity: ${payload.project_id}`);
933
- console.log(`Workspace name: ${payload.workspace_name}`);
934
977
  console.log(`Created pod: ${payload.created_pod ? "yes" : "no"}`);
935
978
  console.log(`Created actor: ${payload.created_actor ? "yes" : "no"}`);
936
979
  console.log(`Linkage mode: ${payload.linkage_mode}`);
980
+ console.log(`Configured workspace ids: ${payload.configured_workspace_ids.join(", ")}`);
981
+ if (payload.gitignore_added.length > 0) {
982
+ console.log(`Updated .gitignore: ${payload.gitignore_added.join(", ")}`);
983
+ }
937
984
  }
938
985
  return 0;
939
986
  }
940
987
  const context = await resolveRemoteContext(parsed);
941
- if (!context.mergedConfig) {
942
- 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}`);
943
990
  }
944
- 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
+ });
945
998
  if (subcommand === "status") {
946
999
  let workspace;
947
1000
  try {
948
- workspace = await client.getWorkspace();
1001
+ workspace = await client.getWorkspace(context.workspaceId);
949
1002
  }
950
1003
  catch (error) {
951
1004
  const message = error instanceof Error ? error.message : String(error);
952
- throw new Error(`Remote status failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1005
+ throw new Error(`Remote status failed using ${linkageLabel}: ${message}`);
953
1006
  }
954
1007
  assertRemoteIdentity(workspace, context.identity);
955
1008
  const remoteIdentity = extractWorkspaceIdentity(workspace);
@@ -958,18 +1011,21 @@ async function main() {
958
1011
  command: "remote",
959
1012
  subcommand: "status",
960
1013
  config_path: context.configPath,
961
- provider: context.mergedConfig.provider,
962
- endpoint: context.mergedConfig.endpoint,
963
- pod_name: context.mergedConfig.pod_name,
964
- 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,
965
1019
  linkage_source: context.linkageSource,
966
1020
  local_identity: context.identity,
967
1021
  remote_identity: {
968
1022
  project_id: remoteIdentity.project_id,
969
- workspace_name: remoteIdentity.workspace_name,
1023
+ workspace_id: remoteIdentity.workspace_id,
1024
+ actor_type: remoteIdentity.actor_type,
1025
+ backend_version: remoteIdentity.backend_version,
970
1026
  },
971
1027
  document_count: remoteIdentity.document_count,
972
- last_imported_at: context.savedConfig?.last_imported_at ?? null,
1028
+ last_imported_at: context.resolvedTarget.last_imported_at ?? null,
973
1029
  workspace,
974
1030
  };
975
1031
  if (output === "json") {
@@ -979,11 +1035,12 @@ async function main() {
979
1035
  console.log("Remote workspace reachable");
980
1036
  console.log(`Config: ${payload.config_path}`);
981
1037
  console.log(`Endpoint: ${payload.endpoint}`);
1038
+ console.log(`Workspace id: ${payload.workspace_id}`);
982
1039
  console.log(`Pod: ${payload.pod_name}`);
983
1040
  console.log(`Actor: ${payload.actor_id}`);
984
1041
  console.log(`Linkage source: ${payload.linkage_source}`);
985
- console.log(`Local identity: ${payload.local_identity.project_id}/${payload.local_identity.workspace_name}`);
986
- 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)})`);
987
1044
  console.log(`Remote document count: ${String(payload.document_count ?? "unknown")}`);
988
1045
  console.log(`Last imported at: ${String(payload.last_imported_at ?? "never")}`);
989
1046
  }
@@ -993,33 +1050,35 @@ async function main() {
993
1050
  const documents = await collectWorkspaceDocuments(parsed.repoRoot);
994
1051
  let importResult;
995
1052
  try {
996
- importResult = validateImportResult(await client.importWorkspace(documents, true));
1053
+ importResult = validateImportResult(await client.importWorkspace(context.workspaceId, documents, true));
997
1054
  }
998
1055
  catch (error) {
999
1056
  const message = error instanceof Error ? error.message : String(error);
1000
- throw new Error(`Remote push failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1057
+ throw new Error(`Remote push failed using ${linkageLabel}: ${message}`);
1001
1058
  }
1002
1059
  let workspace;
1003
1060
  try {
1004
- workspace = await client.getWorkspace();
1061
+ workspace = await client.getWorkspace(context.workspaceId);
1005
1062
  }
1006
1063
  catch (error) {
1007
1064
  const message = error instanceof Error ? error.message : String(error);
1008
- 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}`);
1009
1066
  }
1010
1067
  assertRemoteIdentity(workspace, context.identity);
1011
1068
  const importedAt = new Date().toISOString();
1012
- await updateRemoteConfigMetadata(parsed.repoRoot, { last_imported_at: importedAt });
1069
+ await updateRemoteConfigMetadata(parsed.repoRoot, context.workspaceId, {
1070
+ last_imported_at: importedAt,
1071
+ });
1013
1072
  const payload = {
1014
1073
  status: "ok",
1015
1074
  command: "remote",
1016
1075
  subcommand: "push",
1017
- endpoint: context.mergedConfig.endpoint,
1018
- pod_name: context.mergedConfig.pod_name,
1019
- 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,
1020
1080
  linkage_source: context.linkageSource,
1021
1081
  project_id: context.identity.project_id,
1022
- workspace_name: context.identity.workspace_name,
1023
1082
  local_document_count: documents.length,
1024
1083
  imported: importResult.imported,
1025
1084
  created: importResult.created,
@@ -1035,6 +1094,7 @@ async function main() {
1035
1094
  else {
1036
1095
  console.log("Remote push complete");
1037
1096
  console.log(`Endpoint: ${payload.endpoint}`);
1097
+ console.log(`Workspace id: ${payload.workspace_id}`);
1038
1098
  console.log(`Pod: ${payload.pod_name}`);
1039
1099
  console.log(`Actor: ${payload.actor_id}`);
1040
1100
  console.log(`Linkage source: ${payload.linkage_source}`);
@@ -1052,11 +1112,11 @@ async function main() {
1052
1112
  if (subcommand === "ls") {
1053
1113
  let snapshot;
1054
1114
  try {
1055
- snapshot = await client.getSnapshot(parsed.prefix);
1115
+ snapshot = await client.getSnapshot(context.workspaceId, parsed.prefix);
1056
1116
  }
1057
1117
  catch (error) {
1058
1118
  const message = error instanceof Error ? error.message : String(error);
1059
- throw new Error(`Remote ls failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1119
+ throw new Error(`Remote ls failed using ${linkageLabel}: ${message}`);
1060
1120
  }
1061
1121
  const paths = (snapshot.documents ?? [])
1062
1122
  .map((document) => (typeof document.path === "string" ? document.path : ""))
@@ -1066,9 +1126,10 @@ async function main() {
1066
1126
  status: "ok",
1067
1127
  command: "remote",
1068
1128
  subcommand: "ls",
1069
- endpoint: context.mergedConfig.endpoint,
1070
- pod_name: context.mergedConfig.pod_name,
1071
- 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,
1072
1133
  linkage_source: context.linkageSource,
1073
1134
  prefix: parsed.prefix ?? null,
1074
1135
  paths,
@@ -1096,22 +1157,23 @@ async function main() {
1096
1157
  }
1097
1158
  let document;
1098
1159
  try {
1099
- document = await client.getDocument(documentPath);
1160
+ document = await client.getDocument(context.workspaceId, documentPath);
1100
1161
  }
1101
1162
  catch (error) {
1102
1163
  const message = error instanceof Error ? error.message : String(error);
1103
- throw new Error(`Remote cat failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1164
+ throw new Error(`Remote cat failed using ${linkageLabel}: ${message}`);
1104
1165
  }
1105
1166
  if (!document.document) {
1106
- throw new Error(`Remote document not found using ${describeRemoteLinkage(context.mergedConfig)}: ${documentPath}`);
1167
+ throw new Error(`Remote document not found using ${linkageLabel}: ${documentPath}`);
1107
1168
  }
1108
1169
  const payload = {
1109
1170
  status: "ok",
1110
1171
  command: "remote",
1111
1172
  subcommand: "cat",
1112
- endpoint: context.mergedConfig.endpoint,
1113
- pod_name: context.mergedConfig.pod_name,
1114
- 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,
1115
1177
  linkage_source: context.linkageSource,
1116
1178
  document: document.document,
1117
1179
  };
@@ -0,0 +1,4 @@
1
+ export declare function ensureGitignoreEntries(repoRoot: string, entries: string[]): Promise<{
2
+ path: string;
3
+ added: string[];
4
+ }>;
@@ -0,0 +1,31 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ function normalizeLines(content) {
4
+ return content
5
+ .split(/\r?\n/)
6
+ .map((line) => line.trim())
7
+ .filter(Boolean);
8
+ }
9
+ export async function ensureGitignoreEntries(repoRoot, entries) {
10
+ const gitignorePath = path.join(repoRoot, ".gitignore");
11
+ const current = await fs.readFile(gitignorePath, "utf8").catch(() => "");
12
+ const existing = new Set(normalizeLines(current));
13
+ const added = [];
14
+ for (const entry of entries) {
15
+ const normalized = entry.trim();
16
+ if (!normalized || existing.has(normalized))
17
+ continue;
18
+ existing.add(normalized);
19
+ added.push(normalized);
20
+ }
21
+ if (added.length === 0) {
22
+ return { path: gitignorePath, added };
23
+ }
24
+ const nextLines = current.length > 0 ? current.replace(/\s*$/, "").split(/\r?\n/) : [];
25
+ if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") {
26
+ nextLines.push("");
27
+ }
28
+ nextLines.push(...added);
29
+ await fs.writeFile(gitignorePath, `${nextLines.join("\n")}\n`, "utf8");
30
+ return { path: gitignorePath, added };
31
+ }
package/dist/index.d.ts CHANGED
@@ -3,5 +3,6 @@ export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./man
3
3
  export { runDoctor } from "./doctor.js";
4
4
  export { createPlanScaffold } from "./plan.js";
5
5
  export { loadDotEnvIfPresent, parseDotEnv } from "./env.js";
6
- export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
6
+ export { ensureGitignoreEntries } from "./gitignore.js";
7
+ export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, resolveRemoteTarget, resolveSelectedWorkspaceId, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
7
8
  export { listSpecs, showSpec } from "./spec-runtime.js";
package/dist/index.js CHANGED
@@ -3,5 +3,6 @@ export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./man
3
3
  export { runDoctor } from "./doctor.js";
4
4
  export { createPlanScaffold } from "./plan.js";
5
5
  export { loadDotEnvIfPresent, parseDotEnv } from "./env.js";
6
- export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
6
+ export { ensureGitignoreEntries } from "./gitignore.js";
7
+ export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, resolveRemoteTarget, resolveSelectedWorkspaceId, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
7
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.1",
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
  },