reffy-cli 1.3.0 → 1.4.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
@@ -33,6 +33,7 @@ Command summary:
33
33
  - `reffy plan create`: generates proposal, task, design, and spec scaffolds from indexed Reffy artifacts.
34
34
  - `reffy plan validate|list|show|archive`: manages the planning lifecycle under `.reffy/reffyspec/`.
35
35
  - `reffy spec list|show`: inspects current spec state under `.reffy/reffyspec/`.
36
+ - `reffy remote init|status|push|ls|cat`: links, publishes, and inspects a Paseo-backed remote `.reffy/` workspace.
36
37
  - `reffy diagram render`: renders Mermaid diagrams as SVG or ASCII, including spec-aware generation from compatible `spec.md` files.
37
38
 
38
39
  Output modes:
@@ -54,11 +55,103 @@ reffy plan create --change-id add-login-flow --artifacts login-idea.md
54
55
  reffy plan list --output json
55
56
  reffy plan archive add-login-flow
56
57
  reffy spec show auth --output json
58
+ reffy remote init --endpoint https://your-paseo-endpoint.example --provision
59
+ reffy remote status --output json
60
+ reffy remote push
61
+ reffy remote ls
62
+ reffy remote cat .reffy/manifest.json
57
63
  reffy diagram render --stdin --format svg < diagram.mmd
58
64
  reffy diagram render --input .reffy/reffyspec/specs/auth/spec.md --format ascii
59
65
  reffy diagram render --input .reffy/reffyspec/specs/auth/spec.md --format svg --output .reffy/artifacts/auth-spec.svg
60
66
  ```
61
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
+ ## Remote Sync
94
+
95
+ Reffy can publish the local `.reffy/` workspace to a Paseo-backed remote actor and inspect it later with native CLI commands.
96
+
97
+ The current remote flow is:
98
+
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.
103
+ 5. `reffy remote status|ls|cat` inspects the linked remote workspace.
104
+
105
+ ### Minimal connection requirement
106
+
107
+ For the fresh-provision path, the only required connection value is:
108
+
109
+ - `PASEO_ENDPOINT`
110
+
111
+ Example `.env`:
112
+
113
+ ```bash
114
+ PASEO_ENDPOINT="https://your-paseo-endpoint.example"
115
+ ```
116
+
117
+ That is enough for:
118
+
119
+ ```bash
120
+ reffy remote init --provision
121
+ ```
122
+
123
+ Optional overrides:
124
+
125
+ - `PASEO_POD_NAME` if you want to reuse an existing Paseo pod
126
+ - `PASEO_ACTOR_ID` if you want to reuse an existing backend actor
127
+
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`.
129
+
130
+ ### Saved remote linkage
131
+
132
+ After `reffy remote init`, Reffy stores the local pointer to the linked backend actor in:
133
+
134
+ - `.reffy/state/remote.json`
135
+
136
+ That file contains the connection tuple Reffy uses to reach the backend:
137
+
138
+ - `endpoint`
139
+ - `pod_name`
140
+ - `actor_id`
141
+
142
+ This file is local runtime state. It is not part of the synced remote workspace and is intentionally excluded from `reffy remote push`.
143
+
144
+ ### Example flow
145
+
146
+ ```bash
147
+ reffy init
148
+ reffy remote init --provision
149
+ reffy remote status
150
+ reffy remote push
151
+ reffy remote ls
152
+ reffy remote cat .reffy/manifest.json
153
+ ```
154
+
62
155
  ## Manifest Contract
63
156
 
64
157
  Reffy keeps workspace metadata in `.reffy/manifest.json`. New managed manifests include both core timestamps and workspace identity:
package/dist/cli.js CHANGED
@@ -4,10 +4,12 @@ import { existsSync, promises as fs, statSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { renderDiagram } from "./diagram.js";
6
6
  import { runDoctor } from "./doctor.js";
7
+ import { loadDotEnvIfPresent } from "./env.js";
7
8
  import { archivePlanningChange } from "./plan-archive.js";
8
9
  import { createPlanScaffold } from "./plan.js";
9
10
  import { DEFAULT_PLANNING_RELATIVE_DIR, looksLikePlanningDir, resolveCanonicalPlanningPath } from "./planning-paths.js";
10
11
  import { listPlanningChanges, showPlanningChange, validatePlanningChange } from "./plan-runtime.js";
12
+ import { assertRemoteIdentity, collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, requireWorkspaceIdentity, resolveRemoteConfigPath, } from "./remote.js";
11
13
  import { DEFAULT_REFS_DIRNAME, detectWorkspaceState, looksLikeRefsDir } from "./refs-paths.js";
12
14
  import { prepareCanonicalPlanningLayout } from "./planning-workspace.js";
13
15
  import { ReferencesStore } from "./storage.js";
@@ -431,6 +433,7 @@ function usage() {
431
433
  " summarize Generate a read-only summary of indexed Reffy artifacts.",
432
434
  " plan Generate and manage ReffySpec planning scaffolds from indexed Reffy artifacts.",
433
435
  " spec Inspect current specs from the ReffySpec layout.",
436
+ " remote Link, publish, and inspect a Paseo-backed remote Reffy workspace.",
434
437
  " diagram Render Mermaid diagrams (supports SVG and ASCII).",
435
438
  ].join("\n");
436
439
  }
@@ -478,6 +481,24 @@ function planUsage() {
478
481
  " --force Overwrite an existing non-empty change directory",
479
482
  ].join("\n");
480
483
  }
484
+ function remoteUsage() {
485
+ return [
486
+ "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]",
492
+ "",
493
+ "Options:",
494
+ " --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
+ " --provision Create missing pod/actor during `remote init`.",
498
+ " --prefix PATH Prefix filter for `remote ls`.",
499
+ " --env-file PATH Dotenv file to load before resolving env-backed options. Defaults to .env.",
500
+ ].join("\n");
501
+ }
481
502
  function parseDiagramArgs(argv) {
482
503
  const repoRoot = parseRepoArg(argv);
483
504
  const args = {
@@ -654,6 +675,129 @@ function parsePlanArgs(argv) {
654
675
  }
655
676
  return args;
656
677
  }
678
+ function parseRemoteArgs(argv) {
679
+ const repoRoot = parseRepoArg(argv);
680
+ const args = {
681
+ repoRoot,
682
+ provision: false,
683
+ };
684
+ for (let i = 0; i < argv.length; i += 1) {
685
+ const arg = argv[i];
686
+ if (arg === "--repo") {
687
+ i += 1;
688
+ continue;
689
+ }
690
+ if (arg.startsWith("--repo="))
691
+ continue;
692
+ if (arg === "--json")
693
+ continue;
694
+ if (arg === "--output") {
695
+ i += 1;
696
+ continue;
697
+ }
698
+ if (arg.startsWith("--output="))
699
+ continue;
700
+ if (arg === "--endpoint") {
701
+ const value = argv[i + 1];
702
+ if (!value)
703
+ throw new Error("--endpoint requires a value");
704
+ args.endpoint = value;
705
+ i += 1;
706
+ continue;
707
+ }
708
+ if (arg.startsWith("--endpoint=")) {
709
+ args.endpoint = arg.split("=", 2)[1];
710
+ continue;
711
+ }
712
+ if (arg === "--pod-name") {
713
+ const value = argv[i + 1];
714
+ if (!value)
715
+ throw new Error("--pod-name requires a value");
716
+ args.podName = value;
717
+ i += 1;
718
+ continue;
719
+ }
720
+ if (arg.startsWith("--pod-name=")) {
721
+ args.podName = arg.split("=", 2)[1];
722
+ continue;
723
+ }
724
+ if (arg === "--actor-id") {
725
+ const value = argv[i + 1];
726
+ if (!value)
727
+ throw new Error("--actor-id requires a value");
728
+ args.actorId = value;
729
+ i += 1;
730
+ continue;
731
+ }
732
+ if (arg.startsWith("--actor-id=")) {
733
+ args.actorId = arg.split("=", 2)[1];
734
+ continue;
735
+ }
736
+ if (arg === "--env-file") {
737
+ const value = argv[i + 1];
738
+ if (!value)
739
+ throw new Error("--env-file requires a value");
740
+ args.envFile = value;
741
+ i += 1;
742
+ continue;
743
+ }
744
+ if (arg.startsWith("--env-file=")) {
745
+ args.envFile = arg.split("=", 2)[1];
746
+ continue;
747
+ }
748
+ if (arg === "--prefix") {
749
+ const value = argv[i + 1];
750
+ if (!value)
751
+ throw new Error("--prefix requires a value");
752
+ args.prefix = value;
753
+ i += 1;
754
+ continue;
755
+ }
756
+ if (arg.startsWith("--prefix=")) {
757
+ args.prefix = arg.split("=", 2)[1];
758
+ continue;
759
+ }
760
+ if (arg === "--provision") {
761
+ args.provision = true;
762
+ continue;
763
+ }
764
+ if (arg.startsWith("--")) {
765
+ throw new Error(`Unknown remote option: ${arg}`);
766
+ }
767
+ }
768
+ return args;
769
+ }
770
+ function getRemotePositionalArgs(argv) {
771
+ const positionals = [];
772
+ for (let i = 0; i < argv.length; i += 1) {
773
+ const arg = argv[i];
774
+ if (arg === "--repo" ||
775
+ arg === "--output" ||
776
+ arg === "--endpoint" ||
777
+ arg === "--pod-name" ||
778
+ arg === "--actor-id" ||
779
+ arg === "--env-file" ||
780
+ arg === "--prefix") {
781
+ i += 1;
782
+ continue;
783
+ }
784
+ if (arg.startsWith("--repo=") ||
785
+ arg.startsWith("--output=") ||
786
+ arg.startsWith("--endpoint=") ||
787
+ arg.startsWith("--pod-name=") ||
788
+ arg.startsWith("--actor-id=") ||
789
+ arg.startsWith("--env-file=") ||
790
+ arg.startsWith("--prefix=") ||
791
+ arg === "--json" ||
792
+ arg === "--provision") {
793
+ continue;
794
+ }
795
+ if (arg.startsWith("--"))
796
+ continue;
797
+ positionals.push(arg);
798
+ }
799
+ return positionals;
800
+ }
657
801
  function printSection(title, values) {
658
802
  console.log(`${title}:`);
659
803
  if (values.length === 0) {
@@ -664,6 +808,24 @@ function printSection(title, values) {
664
808
  console.log(`- ${value}`);
665
809
  }
666
810
  }
811
+ async function resolveRemoteContext(args) {
812
+ await loadDotEnvIfPresent(args.repoRoot, args.envFile ?? ".env");
813
+ const store = new ReferencesStore(args.repoRoot);
814
+ const identity = requireWorkspaceIdentity(await store.getWorkspaceIdentity());
815
+ const savedConfig = await readRemoteConfig(args.repoRoot);
816
+ const mergedConfig = mergeRemoteConfig(savedConfig, {
817
+ endpoint: args.endpoint ?? process.env.PASEO_ENDPOINT,
818
+ pod_name: args.podName ?? process.env.PASEO_POD_NAME,
819
+ actor_id: args.actorId ?? process.env.PASEO_ACTOR_ID,
820
+ });
821
+ return {
822
+ store,
823
+ identity,
824
+ configPath: resolveRemoteConfigPath(args.repoRoot),
825
+ savedConfig,
826
+ mergedConfig,
827
+ };
828
+ }
667
829
  async function main() {
668
830
  const [, , command, ...rest] = process.argv;
669
831
  if (!command || command === "--help" || command === "-h") {
@@ -713,6 +875,216 @@ async function main() {
713
875
  }
714
876
  return 0;
715
877
  }
878
+ if (command === "remote") {
879
+ const [subcommand, ...remoteArgs] = rest;
880
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
881
+ console.error(remoteUsage());
882
+ return 1;
883
+ }
884
+ const output = parseOutputMode(remoteArgs);
885
+ const parsed = parseRemoteArgs(remoteArgs);
886
+ try {
887
+ if (subcommand === "init") {
888
+ const { identity } = await resolveRemoteContext(parsed);
889
+ const endpoint = parsed.endpoint ?? process.env.PASEO_ENDPOINT;
890
+ if (!endpoint) {
891
+ throw new Error("Remote init requires --endpoint or PASEO_ENDPOINT (optionally loaded from .env)");
892
+ }
893
+ const result = await ensureRemoteInit(parsed.repoRoot, {
894
+ endpoint,
895
+ podName: parsed.podName ?? process.env.PASEO_POD_NAME,
896
+ actorId: parsed.actorId ?? process.env.PASEO_ACTOR_ID,
897
+ provision: parsed.provision,
898
+ identity,
899
+ });
900
+ const payload = {
901
+ status: "ok",
902
+ command: "remote",
903
+ subcommand: "init",
904
+ provider: result.config.provider,
905
+ config_path: resolveRemoteConfigPath(parsed.repoRoot),
906
+ endpoint: result.config.endpoint,
907
+ pod_name: result.config.pod_name,
908
+ actor_id: result.config.actor_id,
909
+ created_pod: result.created_pod,
910
+ created_actor: result.created_actor,
911
+ project_id: identity.project_id,
912
+ workspace_name: identity.workspace_name,
913
+ };
914
+ if (output === "json") {
915
+ printResult(output, payload);
916
+ }
917
+ else {
918
+ console.log("Remote linkage ready");
919
+ console.log(`Config: ${payload.config_path}`);
920
+ console.log(`Endpoint: ${payload.endpoint}`);
921
+ console.log(`Pod: ${payload.pod_name}`);
922
+ console.log(`Actor: ${payload.actor_id}`);
923
+ console.log(`Project identity: ${payload.project_id}`);
924
+ console.log(`Workspace name: ${payload.workspace_name}`);
925
+ console.log(`Created pod: ${payload.created_pod ? "yes" : "no"}`);
926
+ console.log(`Created actor: ${payload.created_actor ? "yes" : "no"}`);
927
+ }
928
+ return 0;
929
+ }
930
+ const context = await resolveRemoteContext(parsed);
931
+ if (!context.mergedConfig) {
932
+ 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}`);
933
+ }
934
+ const client = new PaseoRemoteClient(context.mergedConfig);
935
+ if (subcommand === "status") {
936
+ const workspace = await client.getWorkspace();
937
+ assertRemoteIdentity(workspace, context.identity);
938
+ const remoteIdentity = extractWorkspaceIdentity(workspace);
939
+ const payload = {
940
+ status: "ok",
941
+ command: "remote",
942
+ subcommand: "status",
943
+ config_path: context.configPath,
944
+ provider: context.mergedConfig.provider,
945
+ endpoint: context.mergedConfig.endpoint,
946
+ pod_name: context.mergedConfig.pod_name,
947
+ actor_id: context.mergedConfig.actor_id,
948
+ local_identity: context.identity,
949
+ remote_identity: {
950
+ project_id: remoteIdentity.project_id,
951
+ workspace_name: remoteIdentity.workspace_name,
952
+ },
953
+ document_count: remoteIdentity.document_count,
954
+ workspace,
955
+ };
956
+ if (output === "json") {
957
+ printResult(output, payload);
958
+ }
959
+ else {
960
+ console.log("Remote workspace reachable");
961
+ console.log(`Config: ${payload.config_path}`);
962
+ console.log(`Endpoint: ${payload.endpoint}`);
963
+ console.log(`Pod: ${payload.pod_name}`);
964
+ console.log(`Actor: ${payload.actor_id}`);
965
+ console.log(`Local identity: ${payload.local_identity.project_id}/${payload.local_identity.workspace_name}`);
966
+ console.log(`Remote identity: ${String(payload.remote_identity.project_id)}/${String(payload.remote_identity.workspace_name)}`);
967
+ console.log(`Remote document count: ${String(payload.document_count ?? "unknown")}`);
968
+ }
969
+ return 0;
970
+ }
971
+ if (subcommand === "push") {
972
+ const documents = await collectWorkspaceDocuments(parsed.repoRoot);
973
+ const importResult = await client.importWorkspace(documents, true);
974
+ const workspace = await client.getWorkspace();
975
+ assertRemoteIdentity(workspace, context.identity);
976
+ const payload = {
977
+ status: "ok",
978
+ command: "remote",
979
+ subcommand: "push",
980
+ endpoint: context.mergedConfig.endpoint,
981
+ pod_name: context.mergedConfig.pod_name,
982
+ actor_id: context.mergedConfig.actor_id,
983
+ project_id: context.identity.project_id,
984
+ workspace_name: context.identity.workspace_name,
985
+ local_document_count: documents.length,
986
+ imported: importResult.imported ?? null,
987
+ created: importResult.created ?? null,
988
+ updated: importResult.updated ?? null,
989
+ deleted: importResult.deleted ?? null,
990
+ remote_document_count: extractWorkspaceIdentity(workspace).document_count,
991
+ };
992
+ if (output === "json") {
993
+ printResult(output, payload);
994
+ }
995
+ else {
996
+ console.log("Remote push complete");
997
+ console.log(`Endpoint: ${payload.endpoint}`);
998
+ console.log(`Pod: ${payload.pod_name}`);
999
+ console.log(`Actor: ${payload.actor_id}`);
1000
+ console.log(`Local document count: ${String(payload.local_document_count)}`);
1001
+ console.log(`Imported: ${String(payload.imported ?? "unknown")}`);
1002
+ console.log(`Created: ${String(payload.created ?? "unknown")}`);
1003
+ console.log(`Updated: ${String(payload.updated ?? "unknown")}`);
1004
+ console.log(`Deleted: ${String(payload.deleted ?? "unknown")}`);
1005
+ console.log(`Remote document count: ${String(payload.remote_document_count ?? "unknown")}`);
1006
+ }
1007
+ return 0;
1008
+ }
1009
+ if (subcommand === "ls") {
1010
+ const snapshot = await client.getSnapshot(parsed.prefix);
1011
+ const paths = (snapshot.documents ?? [])
1012
+ .map((document) => (typeof document.path === "string" ? document.path : ""))
1013
+ .filter(Boolean)
1014
+ .sort();
1015
+ const payload = {
1016
+ status: "ok",
1017
+ command: "remote",
1018
+ subcommand: "ls",
1019
+ endpoint: context.mergedConfig.endpoint,
1020
+ pod_name: context.mergedConfig.pod_name,
1021
+ actor_id: context.mergedConfig.actor_id,
1022
+ prefix: parsed.prefix ?? null,
1023
+ paths,
1024
+ };
1025
+ if (output === "json") {
1026
+ printResult(output, payload);
1027
+ }
1028
+ else if (paths.length === 0) {
1029
+ console.log("(none)");
1030
+ }
1031
+ else {
1032
+ for (const entry of paths) {
1033
+ console.log(entry);
1034
+ }
1035
+ }
1036
+ return 0;
1037
+ }
1038
+ if (subcommand === "cat") {
1039
+ const [documentPath] = getRemotePositionalArgs(remoteArgs);
1040
+ if (!documentPath) {
1041
+ throw new Error("reffy remote cat requires a document path");
1042
+ }
1043
+ if (!documentPath.startsWith(".reffy/")) {
1044
+ throw new Error("reffy remote cat requires a canonical path rooted at .reffy/");
1045
+ }
1046
+ const document = await client.getDocument(documentPath);
1047
+ const payload = {
1048
+ status: "ok",
1049
+ command: "remote",
1050
+ subcommand: "cat",
1051
+ endpoint: context.mergedConfig.endpoint,
1052
+ pod_name: context.mergedConfig.pod_name,
1053
+ actor_id: context.mergedConfig.actor_id,
1054
+ document: document.document ?? null,
1055
+ };
1056
+ if (output === "json") {
1057
+ printResult(output, payload);
1058
+ }
1059
+ else {
1060
+ const content = typeof document.document?.content === "string" ? document.document.content : "";
1061
+ process.stdout.write(content);
1062
+ if (!content.endsWith("\n")) {
1063
+ process.stdout.write("\n");
1064
+ }
1065
+ }
1066
+ return 0;
1067
+ }
1068
+ console.error(`Unknown remote subcommand: ${subcommand}`);
1069
+ console.error(remoteUsage());
1070
+ return 1;
1071
+ }
1072
+ catch (error) {
1073
+ const message = error instanceof Error ? error.message : String(error);
1074
+ if (output === "json") {
1075
+ printResult(output, {
1076
+ status: "error",
1077
+ command: "remote",
1078
+ subcommand,
1079
+ error: message,
1080
+ });
1081
+ }
1082
+ else {
1083
+ console.error(message);
1084
+ }
1085
+ return 1;
1086
+ }
1087
+ }
716
1088
  if (command === "plan") {
717
1089
  const [subcommand, ...planArgs] = rest;
718
1090
  if (!subcommand || subcommand === "--help" || subcommand === "-h") {
package/dist/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function parseDotEnv(content: string): Record<string, string>;
2
+ export declare function loadDotEnvIfPresent(repoRoot: string, envFile?: string): Promise<{
3
+ loaded: boolean;
4
+ path: string;
5
+ }>;
package/dist/env.js ADDED
@@ -0,0 +1,43 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ function stripQuotes(value) {
4
+ if ((value.startsWith('"') && value.endsWith('"')) ||
5
+ (value.startsWith("'") && value.endsWith("'"))) {
6
+ return value.slice(1, -1);
7
+ }
8
+ return value;
9
+ }
10
+ export function parseDotEnv(content) {
11
+ const parsed = {};
12
+ for (const line of content.split(/\r?\n/)) {
13
+ const trimmed = line.trim();
14
+ if (!trimmed || trimmed.startsWith("#"))
15
+ continue;
16
+ const normalized = trimmed.startsWith("export ")
17
+ ? trimmed.slice("export ".length).trim()
18
+ : trimmed;
19
+ const equalsIndex = normalized.indexOf("=");
20
+ if (equalsIndex <= 0)
21
+ continue;
22
+ const key = normalized.slice(0, equalsIndex).trim();
23
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
24
+ continue;
25
+ const rawValue = normalized.slice(equalsIndex + 1).trim();
26
+ parsed[key] = stripQuotes(rawValue);
27
+ }
28
+ return parsed;
29
+ }
30
+ export async function loadDotEnvIfPresent(repoRoot, envFile = ".env") {
31
+ const envPath = path.isAbsolute(envFile) ? envFile : path.join(repoRoot, envFile);
32
+ const text = await fs.readFile(envPath, "utf8").catch(() => null);
33
+ if (text === null) {
34
+ return { loaded: false, path: envPath };
35
+ }
36
+ const parsed = parseDotEnv(text);
37
+ for (const [key, value] of Object.entries(parsed)) {
38
+ if (process.env[key] === undefined) {
39
+ process.env[key] = value;
40
+ }
41
+ }
42
+ return { loaded: true, path: envPath };
43
+ }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,6 @@ export { ReferencesStore } from "./storage.js";
2
2
  export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./manifest.js";
3
3
  export { runDoctor } from "./doctor.js";
4
4
  export { createPlanScaffold } from "./plan.js";
5
+ export { loadDotEnvIfPresent, parseDotEnv } from "./env.js";
6
+ export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
5
7
  export { listSpecs, showSpec } from "./spec-runtime.js";
package/dist/index.js CHANGED
@@ -2,4 +2,6 @@ export { ReferencesStore } from "./storage.js";
2
2
  export { MANIFEST_VERSION, allowedKindExtensions, validateManifest } from "./manifest.js";
3
3
  export { runDoctor } from "./doctor.js";
4
4
  export { createPlanScaffold } from "./plan.js";
5
+ export { loadDotEnvIfPresent, parseDotEnv } from "./env.js";
6
+ export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
5
7
  export { listSpecs, showSpec } from "./spec-runtime.js";
@@ -0,0 +1,56 @@
1
+ import type { RemoteDocument, RemoteImportResult, RemoteLinkConfig, RemoteWorkspaceSummary } from "./types.js";
2
+ export declare const REMOTE_CONFIG_VERSION = 1;
3
+ export declare const REMOTE_PROVIDER = "paseo";
4
+ export interface WorkspaceIdentity {
5
+ project_id: string;
6
+ workspace_name: string;
7
+ }
8
+ export interface EnsureRemoteInitOptions {
9
+ endpoint: string;
10
+ podName?: string;
11
+ actorId?: string;
12
+ provision: boolean;
13
+ identity: WorkspaceIdentity;
14
+ }
15
+ export interface EnsureRemoteInitResult {
16
+ config: RemoteLinkConfig;
17
+ created_pod: boolean;
18
+ created_actor: boolean;
19
+ }
20
+ export declare function resolveRemoteConfigPath(repoRoot: string): string;
21
+ export declare function readRemoteConfig(repoRoot: string): Promise<RemoteLinkConfig | null>;
22
+ export declare function writeRemoteConfig(repoRoot: string, config: RemoteLinkConfig): Promise<string>;
23
+ export declare function mergeRemoteConfig(stored: RemoteLinkConfig | null, overrides: Partial<Pick<RemoteLinkConfig, "endpoint" | "pod_name" | "actor_id">>): RemoteLinkConfig | null;
24
+ export declare class PaseoRemoteClient {
25
+ private readonly config;
26
+ constructor(config: Pick<RemoteLinkConfig, "endpoint" | "pod_name" | "actor_id">);
27
+ createPod(): Promise<string>;
28
+ createActor(identity: WorkspaceIdentity): Promise<string>;
29
+ getWorkspace(): Promise<RemoteWorkspaceSummary>;
30
+ importWorkspace(documents: RemoteDocument[], replaceMissing?: boolean): Promise<RemoteImportResult>;
31
+ getSnapshot(prefix?: string): Promise<{
32
+ documents?: Array<{
33
+ path?: string;
34
+ }>;
35
+ }>;
36
+ getDocument(documentPath: string): Promise<{
37
+ document?: {
38
+ path?: string;
39
+ content?: string;
40
+ content_type?: string;
41
+ };
42
+ }>;
43
+ }
44
+ export declare function ensureRemoteInit(repoRoot: string, options: EnsureRemoteInitOptions): Promise<EnsureRemoteInitResult>;
45
+ export declare function requireWorkspaceIdentity(identity: {
46
+ project_id?: string;
47
+ workspace_name?: string;
48
+ }): WorkspaceIdentity;
49
+ export declare function extractWorkspaceIdentity(summary: RemoteWorkspaceSummary): {
50
+ project_id?: string | null;
51
+ workspace_name?: string | null;
52
+ document_count?: number | null;
53
+ };
54
+ export declare function assertRemoteIdentity(summary: RemoteWorkspaceSummary, identity: WorkspaceIdentity): void;
55
+ export declare function toCanonicalRemotePath(refsDir: string, filePath: string): string;
56
+ export declare function collectWorkspaceDocuments(repoRoot: string): Promise<RemoteDocument[]>;
package/dist/remote.js ADDED
@@ -0,0 +1,275 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveRefsDir } from "./refs-paths.js";
4
+ export const REMOTE_CONFIG_VERSION = 1;
5
+ export const REMOTE_PROVIDER = "paseo";
6
+ const REMOTE_STATE_DIR = path.join("state");
7
+ const REMOTE_CONFIG_FILE = "remote.json";
8
+ function isObject(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ function isNonEmptyString(value) {
12
+ return typeof value === "string" && value.trim().length > 0;
13
+ }
14
+ function normalizeSeparators(value) {
15
+ return value.split(path.sep).join("/");
16
+ }
17
+ export function resolveRemoteConfigPath(repoRoot) {
18
+ return path.join(resolveRefsDir(repoRoot), REMOTE_STATE_DIR, REMOTE_CONFIG_FILE);
19
+ }
20
+ export async function readRemoteConfig(repoRoot) {
21
+ const configPath = resolveRemoteConfigPath(repoRoot);
22
+ const raw = await fs.readFile(configPath, "utf8").catch(() => null);
23
+ if (raw === null)
24
+ return null;
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ }
29
+ catch (error) {
30
+ throw new Error(`Failed to parse remote config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ if (!isObject(parsed)) {
33
+ throw new Error(`Remote config at ${configPath} must be an object`);
34
+ }
35
+ if (parsed.version !== REMOTE_CONFIG_VERSION) {
36
+ throw new Error(`Remote config at ${configPath} must use version ${String(REMOTE_CONFIG_VERSION)}`);
37
+ }
38
+ if (parsed.provider !== REMOTE_PROVIDER) {
39
+ throw new Error(`Remote config at ${configPath} must use provider "${REMOTE_PROVIDER}"`);
40
+ }
41
+ if (!isNonEmptyString(parsed.endpoint)) {
42
+ throw new Error(`Remote config at ${configPath} must define endpoint`);
43
+ }
44
+ if (!isNonEmptyString(parsed.pod_name)) {
45
+ throw new Error(`Remote config at ${configPath} must define pod_name`);
46
+ }
47
+ if (!isNonEmptyString(parsed.actor_id)) {
48
+ throw new Error(`Remote config at ${configPath} must define actor_id`);
49
+ }
50
+ return {
51
+ version: REMOTE_CONFIG_VERSION,
52
+ provider: REMOTE_PROVIDER,
53
+ endpoint: parsed.endpoint.trim(),
54
+ pod_name: parsed.pod_name.trim(),
55
+ actor_id: parsed.actor_id.trim(),
56
+ last_imported_at: isNonEmptyString(parsed.last_imported_at) ? parsed.last_imported_at.trim() : undefined,
57
+ };
58
+ }
59
+ export async function writeRemoteConfig(repoRoot, config) {
60
+ const configPath = resolveRemoteConfigPath(repoRoot);
61
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
62
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
63
+ return configPath;
64
+ }
65
+ export function mergeRemoteConfig(stored, overrides) {
66
+ if (!stored && !overrides.endpoint && !overrides.pod_name && !overrides.actor_id) {
67
+ return null;
68
+ }
69
+ const endpoint = overrides.endpoint ?? stored?.endpoint;
70
+ const pod_name = overrides.pod_name ?? stored?.pod_name;
71
+ const actor_id = overrides.actor_id ?? stored?.actor_id;
72
+ if (!endpoint || !pod_name || !actor_id) {
73
+ return null;
74
+ }
75
+ return {
76
+ version: REMOTE_CONFIG_VERSION,
77
+ provider: REMOTE_PROVIDER,
78
+ endpoint,
79
+ pod_name,
80
+ actor_id,
81
+ last_imported_at: stored?.last_imported_at,
82
+ };
83
+ }
84
+ async function http(url, init = {}) {
85
+ const headers = new Headers(init.headers ?? {});
86
+ if (!headers.has("Content-Type") && init.body) {
87
+ headers.set("Content-Type", "application/json");
88
+ }
89
+ const response = await fetch(url, { ...init, headers });
90
+ if (!response.ok) {
91
+ const text = await response.text().catch(() => "");
92
+ throw new Error(`${response.status} ${response.statusText} - ${text}`);
93
+ }
94
+ return response;
95
+ }
96
+ async function httpJson(url, init = {}) {
97
+ const response = await http(url, init);
98
+ return (await response.json());
99
+ }
100
+ function actorBase(config) {
101
+ return `${config.endpoint}/pods/${config.pod_name}/actors/${config.actor_id}`;
102
+ }
103
+ export class PaseoRemoteClient {
104
+ config;
105
+ constructor(config) {
106
+ this.config = config;
107
+ }
108
+ async createPod() {
109
+ const result = await httpJson(`${this.config.endpoint}/pods`, { method: "POST" });
110
+ if (!isNonEmptyString(result.podName)) {
111
+ throw new Error("Paseo create pod response missing podName");
112
+ }
113
+ return result.podName.trim();
114
+ }
115
+ async createActor(identity) {
116
+ const result = await httpJson(`${this.config.endpoint}/pods/${this.config.pod_name}/actors`, {
117
+ method: "POST",
118
+ body: JSON.stringify({
119
+ config: {
120
+ actorType: "reffyRemoteBackend",
121
+ version: "v1",
122
+ schema: { type: "object" },
123
+ params: {
124
+ project_id: identity.project_id,
125
+ workspace_name: identity.workspace_name,
126
+ default_lock_ttl_seconds: 120,
127
+ max_snapshot_documents: 1000,
128
+ },
129
+ },
130
+ }),
131
+ });
132
+ if (!isNonEmptyString(result.actorId)) {
133
+ throw new Error("Paseo create actor response missing actorId");
134
+ }
135
+ return result.actorId.trim();
136
+ }
137
+ async getWorkspace() {
138
+ return await httpJson(`${actorBase(this.config)}/workspace`);
139
+ }
140
+ async importWorkspace(documents, replaceMissing = true) {
141
+ return await httpJson(`${actorBase(this.config)}/workspace/import`, {
142
+ method: "POST",
143
+ body: JSON.stringify({
144
+ documents,
145
+ replace_missing: replaceMissing,
146
+ }),
147
+ });
148
+ }
149
+ async getSnapshot(prefix) {
150
+ const query = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
151
+ return await httpJson(`${actorBase(this.config)}/workspace/snapshot${query}`);
152
+ }
153
+ async getDocument(documentPath) {
154
+ return await httpJson(`${actorBase(this.config)}/workspace/documents?path=${encodeURIComponent(documentPath)}`);
155
+ }
156
+ }
157
+ export async function ensureRemoteInit(repoRoot, options) {
158
+ let created_pod = false;
159
+ let created_actor = false;
160
+ let podName = options.podName?.trim() || "";
161
+ let actorId = options.actorId?.trim() || "";
162
+ if (!options.endpoint.trim()) {
163
+ throw new Error("Remote init requires an endpoint");
164
+ }
165
+ if (!podName && !options.provision) {
166
+ throw new Error("Remote init requires --pod-name unless --provision is set");
167
+ }
168
+ const baseClient = new PaseoRemoteClient({
169
+ endpoint: options.endpoint.trim(),
170
+ pod_name: podName || "pending-pod",
171
+ actor_id: actorId || "pending-actor",
172
+ });
173
+ if (!podName) {
174
+ podName = await baseClient.createPod();
175
+ created_pod = true;
176
+ }
177
+ if (!actorId) {
178
+ if (!options.provision) {
179
+ throw new Error("Remote init requires --actor-id unless --provision is set");
180
+ }
181
+ const client = new PaseoRemoteClient({
182
+ endpoint: options.endpoint.trim(),
183
+ pod_name: podName,
184
+ actor_id: "pending-actor",
185
+ });
186
+ actorId = await client.createActor(options.identity);
187
+ created_actor = true;
188
+ }
189
+ const config = {
190
+ version: REMOTE_CONFIG_VERSION,
191
+ provider: REMOTE_PROVIDER,
192
+ endpoint: options.endpoint.trim(),
193
+ pod_name: podName,
194
+ actor_id: actorId,
195
+ };
196
+ await writeRemoteConfig(repoRoot, config);
197
+ return { config, created_pod, created_actor };
198
+ }
199
+ export function requireWorkspaceIdentity(identity) {
200
+ const project_id = identity.project_id?.trim();
201
+ const workspace_name = identity.workspace_name?.trim();
202
+ if (!project_id || !workspace_name) {
203
+ throw new Error("Remote sync requires .reffy/manifest.json to define project_id and workspace_name");
204
+ }
205
+ return { project_id, workspace_name };
206
+ }
207
+ export function extractWorkspaceIdentity(summary) {
208
+ const workspace = isObject(summary.workspace) ? summary.workspace : {};
209
+ const stats = isObject(summary.stats) ? summary.stats : {};
210
+ return {
211
+ project_id: typeof workspace.project_id === "string" ? workspace.project_id : null,
212
+ workspace_name: typeof workspace.workspace_name === "string" ? workspace.workspace_name : null,
213
+ document_count: typeof stats.document_count === "number" ? stats.document_count : null,
214
+ };
215
+ }
216
+ export function assertRemoteIdentity(summary, identity) {
217
+ const remote = extractWorkspaceIdentity(summary);
218
+ if (remote.project_id !== identity.project_id || remote.workspace_name !== identity.workspace_name) {
219
+ throw new Error(`Remote identity mismatch: local=${identity.project_id}/${identity.workspace_name} remote=${String(remote.project_id)}/${String(remote.workspace_name)}`);
220
+ }
221
+ }
222
+ async function collectFiles(dirPath) {
223
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
224
+ const files = [];
225
+ for (const entry of entries) {
226
+ const fullPath = path.join(dirPath, entry.name);
227
+ if (entry.isDirectory()) {
228
+ if (entry.name === "state")
229
+ continue;
230
+ files.push(...(await collectFiles(fullPath)));
231
+ continue;
232
+ }
233
+ if (entry.isFile()) {
234
+ files.push(fullPath);
235
+ }
236
+ }
237
+ return files;
238
+ }
239
+ function inferContentType(filePath) {
240
+ if (filePath.endsWith(".json"))
241
+ return "application/json";
242
+ if (filePath.endsWith(".md"))
243
+ return "text/markdown";
244
+ return "text/plain";
245
+ }
246
+ export function toCanonicalRemotePath(refsDir, filePath) {
247
+ const relative = normalizeSeparators(path.relative(refsDir, filePath));
248
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
249
+ throw new Error(`Cannot convert ${filePath} into a canonical .reffy path`);
250
+ }
251
+ return `.reffy/${relative}`;
252
+ }
253
+ export async function collectWorkspaceDocuments(repoRoot) {
254
+ const refsDir = resolveRefsDir(repoRoot);
255
+ const stat = await fs.stat(refsDir).catch(() => null);
256
+ if (!stat || !stat.isDirectory()) {
257
+ throw new Error(`Reffy directory not found: ${refsDir}`);
258
+ }
259
+ const files = await collectFiles(refsDir);
260
+ const documents = [];
261
+ for (const fullPath of files.sort()) {
262
+ const content = await fs.readFile(fullPath, "utf8");
263
+ const remotePath = toCanonicalRemotePath(refsDir, fullPath);
264
+ documents.push({
265
+ path: remotePath,
266
+ content,
267
+ content_type: inferContentType(remotePath),
268
+ metadata: {
269
+ source: "local-reffy-import",
270
+ size_bytes: Buffer.byteLength(content, "utf8"),
271
+ },
272
+ });
273
+ }
274
+ return documents;
275
+ }
package/dist/types.d.ts CHANGED
@@ -20,3 +20,27 @@ export interface Manifest {
20
20
  workspace_name?: string;
21
21
  artifacts: Artifact[];
22
22
  }
23
+ export interface RemoteLinkConfig {
24
+ version: number;
25
+ provider: "paseo";
26
+ endpoint: string;
27
+ pod_name: string;
28
+ actor_id: string;
29
+ last_imported_at?: string;
30
+ }
31
+ export interface RemoteDocument {
32
+ path: string;
33
+ content: string;
34
+ content_type: string;
35
+ metadata?: Record<string, unknown>;
36
+ }
37
+ export interface RemoteImportResult {
38
+ imported?: number;
39
+ created?: number;
40
+ updated?: number;
41
+ deleted?: number;
42
+ }
43
+ export interface RemoteWorkspaceSummary {
44
+ workspace?: Record<string, unknown>;
45
+ stats?: Record<string, unknown>;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Planning system for AI assisted development.",
5
5
  "keywords": [
6
6
  "reffy",
@@ -35,6 +35,7 @@
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",
38
39
  "test": "npm run build && vitest run --coverage",
39
40
  "test:watch": "vitest"
40
41
  },