reffy-cli 1.4.0 → 1.4.2

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
@@ -99,8 +99,8 @@ The current remote flow is:
99
99
  1. Reffy reads `project_id` and `workspace_name` from `.reffy/manifest.json`.
100
100
  2. Reffy connects to Paseo using `PASEO_ENDPOINT` or `--endpoint`.
101
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.
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.
104
104
 
105
105
  ### Minimal connection requirement
106
106
 
@@ -141,6 +141,8 @@ That file contains the connection tuple Reffy uses to reach the backend:
141
141
 
142
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
143
 
144
+ The file can also record local sync metadata such as the last successful import timestamp. That metadata is for local diagnostics and does not become part of the remote workspace contract.
145
+
144
146
  ### Example flow
145
147
 
146
148
  ```bash
@@ -152,6 +154,23 @@ reffy remote ls
152
154
  reffy remote cat .reffy/manifest.json
153
155
  ```
154
156
 
157
+ `reffy remote status` is the primary diagnostic command for this flow. It reports:
158
+
159
+ - the saved linkage being used
160
+ - the local manifest identity
161
+ - the remote workspace identity
162
+ - remote document counts when reachable
163
+
164
+ `reffy remote push` reports:
165
+
166
+ - local document count
167
+ - imported count
168
+ - created count
169
+ - updated count
170
+ - deleted count
171
+
172
+ That makes the default prune/import behavior auditable without dropping to direct backend API calls.
173
+
155
174
  ## Manifest Contract
156
175
 
157
176
  Reffy keeps workspace metadata in `.reffy/manifest.json`. New managed manifests include both core timestamps and workspace identity:
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, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, requireWorkspaceIdentity, resolveRemoteConfigPath, } from "./remote.js";
13
+ import { assertRemoteIdentity, collectWorkspaceDocuments, describeRemoteLinkage, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, requireWorkspaceIdentity, resolveRemoteConfigPath, 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";
@@ -813,17 +814,25 @@ async function resolveRemoteContext(args) {
813
814
  const store = new ReferencesStore(args.repoRoot);
814
815
  const identity = requireWorkspaceIdentity(await store.getWorkspaceIdentity());
815
816
  const savedConfig = await readRemoteConfig(args.repoRoot);
816
- const mergedConfig = mergeRemoteConfig(savedConfig, {
817
+ const overrideValues = {
817
818
  endpoint: args.endpoint ?? process.env.PASEO_ENDPOINT,
818
819
  pod_name: args.podName ?? process.env.PASEO_POD_NAME,
819
820
  actor_id: args.actorId ?? process.env.PASEO_ACTOR_ID,
820
- });
821
+ };
822
+ const mergedConfig = mergeRemoteConfig(savedConfig, overrideValues);
823
+ const hasOverrides = Boolean(overrideValues.endpoint || overrideValues.pod_name || overrideValues.actor_id);
824
+ const linkageSource = mergedConfig
825
+ ? savedConfig && !hasOverrides
826
+ ? "saved_config"
827
+ : "cli_or_env_overrides"
828
+ : "unconfigured";
821
829
  return {
822
830
  store,
823
831
  identity,
824
832
  configPath: resolveRemoteConfigPath(args.repoRoot),
825
833
  savedConfig,
826
834
  mergedConfig,
835
+ linkageSource,
827
836
  };
828
837
  }
829
838
  async function main() {
@@ -885,7 +894,7 @@ async function main() {
885
894
  const parsed = parseRemoteArgs(remoteArgs);
886
895
  try {
887
896
  if (subcommand === "init") {
888
- const { identity } = await resolveRemoteContext(parsed);
897
+ const { identity, configPath } = await resolveRemoteContext(parsed);
889
898
  const endpoint = parsed.endpoint ?? process.env.PASEO_ENDPOINT;
890
899
  if (!endpoint) {
891
900
  throw new Error("Remote init requires --endpoint or PASEO_ENDPOINT (optionally loaded from .env)");
@@ -897,12 +906,13 @@ async function main() {
897
906
  provision: parsed.provision,
898
907
  identity,
899
908
  });
909
+ const gitignore = await ensureGitignoreEntries(parsed.repoRoot, [".reffy/state/"]);
900
910
  const payload = {
901
911
  status: "ok",
902
912
  command: "remote",
903
913
  subcommand: "init",
904
914
  provider: result.config.provider,
905
- config_path: resolveRemoteConfigPath(parsed.repoRoot),
915
+ config_path: configPath,
906
916
  endpoint: result.config.endpoint,
907
917
  pod_name: result.config.pod_name,
908
918
  actor_id: result.config.actor_id,
@@ -910,6 +920,9 @@ async function main() {
910
920
  created_actor: result.created_actor,
911
921
  project_id: identity.project_id,
912
922
  workspace_name: identity.workspace_name,
923
+ linkage_mode: result.created_pod || result.created_actor ? "provisioned" : "existing",
924
+ gitignore_path: gitignore.path,
925
+ gitignore_added: gitignore.added,
913
926
  };
914
927
  if (output === "json") {
915
928
  printResult(output, payload);
@@ -924,6 +937,10 @@ async function main() {
924
937
  console.log(`Workspace name: ${payload.workspace_name}`);
925
938
  console.log(`Created pod: ${payload.created_pod ? "yes" : "no"}`);
926
939
  console.log(`Created actor: ${payload.created_actor ? "yes" : "no"}`);
940
+ console.log(`Linkage mode: ${payload.linkage_mode}`);
941
+ if (payload.gitignore_added.length > 0) {
942
+ console.log(`Updated .gitignore: ${payload.gitignore_added.join(", ")}`);
943
+ }
927
944
  }
928
945
  return 0;
929
946
  }
@@ -933,7 +950,14 @@ async function main() {
933
950
  }
934
951
  const client = new PaseoRemoteClient(context.mergedConfig);
935
952
  if (subcommand === "status") {
936
- const workspace = await client.getWorkspace();
953
+ let workspace;
954
+ try {
955
+ workspace = await client.getWorkspace();
956
+ }
957
+ catch (error) {
958
+ const message = error instanceof Error ? error.message : String(error);
959
+ throw new Error(`Remote status failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
960
+ }
937
961
  assertRemoteIdentity(workspace, context.identity);
938
962
  const remoteIdentity = extractWorkspaceIdentity(workspace);
939
963
  const payload = {
@@ -945,12 +969,14 @@ async function main() {
945
969
  endpoint: context.mergedConfig.endpoint,
946
970
  pod_name: context.mergedConfig.pod_name,
947
971
  actor_id: context.mergedConfig.actor_id,
972
+ linkage_source: context.linkageSource,
948
973
  local_identity: context.identity,
949
974
  remote_identity: {
950
975
  project_id: remoteIdentity.project_id,
951
976
  workspace_name: remoteIdentity.workspace_name,
952
977
  },
953
978
  document_count: remoteIdentity.document_count,
979
+ last_imported_at: context.savedConfig?.last_imported_at ?? null,
954
980
  workspace,
955
981
  };
956
982
  if (output === "json") {
@@ -962,17 +988,35 @@ async function main() {
962
988
  console.log(`Endpoint: ${payload.endpoint}`);
963
989
  console.log(`Pod: ${payload.pod_name}`);
964
990
  console.log(`Actor: ${payload.actor_id}`);
991
+ console.log(`Linkage source: ${payload.linkage_source}`);
965
992
  console.log(`Local identity: ${payload.local_identity.project_id}/${payload.local_identity.workspace_name}`);
966
993
  console.log(`Remote identity: ${String(payload.remote_identity.project_id)}/${String(payload.remote_identity.workspace_name)}`);
967
994
  console.log(`Remote document count: ${String(payload.document_count ?? "unknown")}`);
995
+ console.log(`Last imported at: ${String(payload.last_imported_at ?? "never")}`);
968
996
  }
969
997
  return 0;
970
998
  }
971
999
  if (subcommand === "push") {
972
1000
  const documents = await collectWorkspaceDocuments(parsed.repoRoot);
973
- const importResult = await client.importWorkspace(documents, true);
974
- const workspace = await client.getWorkspace();
1001
+ let importResult;
1002
+ try {
1003
+ importResult = validateImportResult(await client.importWorkspace(documents, true));
1004
+ }
1005
+ catch (error) {
1006
+ const message = error instanceof Error ? error.message : String(error);
1007
+ throw new Error(`Remote push failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1008
+ }
1009
+ let workspace;
1010
+ try {
1011
+ workspace = await client.getWorkspace();
1012
+ }
1013
+ catch (error) {
1014
+ const message = error instanceof Error ? error.message : String(error);
1015
+ throw new Error(`Remote push could not verify remote state using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1016
+ }
975
1017
  assertRemoteIdentity(workspace, context.identity);
1018
+ const importedAt = new Date().toISOString();
1019
+ await updateRemoteConfigMetadata(parsed.repoRoot, { last_imported_at: importedAt });
976
1020
  const payload = {
977
1021
  status: "ok",
978
1022
  command: "remote",
@@ -980,13 +1024,16 @@ async function main() {
980
1024
  endpoint: context.mergedConfig.endpoint,
981
1025
  pod_name: context.mergedConfig.pod_name,
982
1026
  actor_id: context.mergedConfig.actor_id,
1027
+ linkage_source: context.linkageSource,
983
1028
  project_id: context.identity.project_id,
984
1029
  workspace_name: context.identity.workspace_name,
985
1030
  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,
1031
+ imported: importResult.imported,
1032
+ created: importResult.created,
1033
+ updated: importResult.updated,
1034
+ deleted: importResult.deleted,
1035
+ replace_missing: true,
1036
+ last_imported_at: importedAt,
990
1037
  remote_document_count: extractWorkspaceIdentity(workspace).document_count,
991
1038
  };
992
1039
  if (output === "json") {
@@ -997,17 +1044,27 @@ async function main() {
997
1044
  console.log(`Endpoint: ${payload.endpoint}`);
998
1045
  console.log(`Pod: ${payload.pod_name}`);
999
1046
  console.log(`Actor: ${payload.actor_id}`);
1047
+ console.log(`Linkage source: ${payload.linkage_source}`);
1048
+ console.log("Mode: remote reflects local (replace_missing=true)");
1000
1049
  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")}`);
1050
+ console.log(`Imported: ${String(payload.imported)}`);
1051
+ console.log(`Created: ${String(payload.created)}`);
1052
+ console.log(`Updated: ${String(payload.updated)}`);
1053
+ console.log(`Deleted: ${String(payload.deleted)}`);
1005
1054
  console.log(`Remote document count: ${String(payload.remote_document_count ?? "unknown")}`);
1055
+ console.log(`Last imported at: ${payload.last_imported_at}`);
1006
1056
  }
1007
1057
  return 0;
1008
1058
  }
1009
1059
  if (subcommand === "ls") {
1010
- const snapshot = await client.getSnapshot(parsed.prefix);
1060
+ let snapshot;
1061
+ try {
1062
+ snapshot = await client.getSnapshot(parsed.prefix);
1063
+ }
1064
+ catch (error) {
1065
+ const message = error instanceof Error ? error.message : String(error);
1066
+ throw new Error(`Remote ls failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1067
+ }
1011
1068
  const paths = (snapshot.documents ?? [])
1012
1069
  .map((document) => (typeof document.path === "string" ? document.path : ""))
1013
1070
  .filter(Boolean)
@@ -1019,6 +1076,7 @@ async function main() {
1019
1076
  endpoint: context.mergedConfig.endpoint,
1020
1077
  pod_name: context.mergedConfig.pod_name,
1021
1078
  actor_id: context.mergedConfig.actor_id,
1079
+ linkage_source: context.linkageSource,
1022
1080
  prefix: parsed.prefix ?? null,
1023
1081
  paths,
1024
1082
  };
@@ -1043,7 +1101,17 @@ async function main() {
1043
1101
  if (!documentPath.startsWith(".reffy/")) {
1044
1102
  throw new Error("reffy remote cat requires a canonical path rooted at .reffy/");
1045
1103
  }
1046
- const document = await client.getDocument(documentPath);
1104
+ let document;
1105
+ try {
1106
+ document = await client.getDocument(documentPath);
1107
+ }
1108
+ catch (error) {
1109
+ const message = error instanceof Error ? error.message : String(error);
1110
+ throw new Error(`Remote cat failed using ${describeRemoteLinkage(context.mergedConfig)}: ${message}`);
1111
+ }
1112
+ if (!document.document) {
1113
+ throw new Error(`Remote document not found using ${describeRemoteLinkage(context.mergedConfig)}: ${documentPath}`);
1114
+ }
1047
1115
  const payload = {
1048
1116
  status: "ok",
1049
1117
  command: "remote",
@@ -1051,13 +1119,14 @@ async function main() {
1051
1119
  endpoint: context.mergedConfig.endpoint,
1052
1120
  pod_name: context.mergedConfig.pod_name,
1053
1121
  actor_id: context.mergedConfig.actor_id,
1054
- document: document.document ?? null,
1122
+ linkage_source: context.linkageSource,
1123
+ document: document.document,
1055
1124
  };
1056
1125
  if (output === "json") {
1057
1126
  printResult(output, payload);
1058
1127
  }
1059
1128
  else {
1060
- const content = typeof document.document?.content === "string" ? document.document.content : "";
1129
+ const content = typeof document.document.content === "string" ? document.document.content : "";
1061
1130
  process.stdout.write(content);
1062
1131
  if (!content.endsWith("\n")) {
1063
1132
  process.stdout.write("\n");
@@ -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 { ensureGitignoreEntries } from "./gitignore.js";
6
7
  export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, 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 { ensureGitignoreEntries } from "./gitignore.js";
6
7
  export { collectWorkspaceDocuments, ensureRemoteInit, extractWorkspaceIdentity, mergeRemoteConfig, PaseoRemoteClient, readRemoteConfig, resolveRemoteConfigPath, toCanonicalRemotePath, writeRemoteConfig, } from "./remote.js";
7
8
  export { listSpecs, showSpec } from "./spec-runtime.js";
package/dist/remote.d.ts CHANGED
@@ -17,10 +17,18 @@ export interface EnsureRemoteInitResult {
17
17
  created_pod: boolean;
18
18
  created_actor: boolean;
19
19
  }
20
+ export interface ValidatedImportResult {
21
+ imported: number;
22
+ created: number;
23
+ updated: number;
24
+ deleted: number;
25
+ }
20
26
  export declare function resolveRemoteConfigPath(repoRoot: string): string;
21
27
  export declare function readRemoteConfig(repoRoot: string): Promise<RemoteLinkConfig | null>;
22
28
  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>;
23
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;
24
32
  export declare class PaseoRemoteClient {
25
33
  private readonly config;
26
34
  constructor(config: Pick<RemoteLinkConfig, "endpoint" | "pod_name" | "actor_id">);
@@ -52,5 +60,6 @@ export declare function extractWorkspaceIdentity(summary: RemoteWorkspaceSummary
52
60
  document_count?: number | null;
53
61
  };
54
62
  export declare function assertRemoteIdentity(summary: RemoteWorkspaceSummary, identity: WorkspaceIdentity): void;
63
+ export declare function validateImportResult(result: RemoteImportResult): ValidatedImportResult;
55
64
  export declare function toCanonicalRemotePath(refsDir: string, filePath: string): string;
56
65
  export declare function collectWorkspaceDocuments(repoRoot: string): Promise<RemoteDocument[]>;
package/dist/remote.js CHANGED
@@ -62,6 +62,17 @@ export async function writeRemoteConfig(repoRoot, config) {
62
62
  await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
63
63
  return configPath;
64
64
  }
65
+ export async function updateRemoteConfigMetadata(repoRoot, patch) {
66
+ const existing = await readRemoteConfig(repoRoot);
67
+ if (!existing)
68
+ return null;
69
+ const next = {
70
+ ...existing,
71
+ ...patch,
72
+ };
73
+ await writeRemoteConfig(repoRoot, next);
74
+ return next;
75
+ }
65
76
  export function mergeRemoteConfig(stored, overrides) {
66
77
  if (!stored && !overrides.endpoint && !overrides.pod_name && !overrides.actor_id) {
67
78
  return null;
@@ -81,6 +92,9 @@ export function mergeRemoteConfig(stored, overrides) {
81
92
  last_imported_at: stored?.last_imported_at,
82
93
  };
83
94
  }
95
+ export function describeRemoteLinkage(config) {
96
+ return `endpoint=${config.endpoint} pod=${config.pod_name} actor=${config.actor_id}`;
97
+ }
84
98
  async function http(url, init = {}) {
85
99
  const headers = new Headers(init.headers ?? {});
86
100
  if (!headers.has("Content-Type") && init.body) {
@@ -219,6 +233,19 @@ export function assertRemoteIdentity(summary, identity) {
219
233
  throw new Error(`Remote identity mismatch: local=${identity.project_id}/${identity.workspace_name} remote=${String(remote.project_id)}/${String(remote.workspace_name)}`);
220
234
  }
221
235
  }
236
+ export function validateImportResult(result) {
237
+ const requiredKeys = ["imported", "created", "updated", "deleted"];
238
+ const missing = requiredKeys.filter((key) => typeof result[key] !== "number");
239
+ if (missing.length > 0) {
240
+ throw new Error(`Remote import response missing numeric field(s): ${missing.join(", ")}`);
241
+ }
242
+ return {
243
+ imported: result.imported,
244
+ created: result.created,
245
+ updated: result.updated,
246
+ deleted: result.deleted,
247
+ };
248
+ }
222
249
  async function collectFiles(dirPath) {
223
250
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
224
251
  const files = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy-cli",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Planning system for AI assisted development.",
5
5
  "keywords": [
6
6
  "reffy",