reffy-cli 1.2.1 → 1.3.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
@@ -59,6 +59,23 @@ reffy diagram render --input .reffy/reffyspec/specs/auth/spec.md --format ascii
59
59
  reffy diagram render --input .reffy/reffyspec/specs/auth/spec.md --format svg --output .reffy/artifacts/auth-spec.svg
60
60
  ```
61
61
 
62
+ ## Manifest Contract
63
+
64
+ Reffy keeps workspace metadata in `.reffy/manifest.json`. New managed manifests include both core timestamps and workspace identity:
65
+
66
+ ```json
67
+ {
68
+ "version": 1,
69
+ "created_at": "2026-04-18T00:00:00.000Z",
70
+ "updated_at": "2026-04-18T00:00:00.000Z",
71
+ "project_id": "my-project",
72
+ "workspace_name": "my-project",
73
+ "artifacts": []
74
+ }
75
+ ```
76
+
77
+ 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.
78
+
62
79
  ## Using Reffy With ReffySpec
63
80
 
64
81
  A practical pattern is:
@@ -1,6 +1,9 @@
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">>;
5
+ export declare function createManifest(repoRoot: string, now?: string): Manifest;
6
+ export declare function normalizeManifest(raw: unknown, repoRoot: string): Manifest;
4
7
  export declare function inferArtifactType(filePath: string): {
5
8
  kind: string;
6
9
  mime_type: string;
package/dist/manifest.js CHANGED
@@ -12,6 +12,8 @@ const KIND_EXTENSIONS = {
12
12
  doc: [".doc", ".docx"],
13
13
  file: [],
14
14
  };
15
+ const PROJECT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const FALLBACK_WORKSPACE_ID = "reffy-workspace";
15
17
  function isObject(value) {
16
18
  return typeof value === "object" && value !== null && !Array.isArray(value);
17
19
  }
@@ -20,6 +22,21 @@ function isIsoDate(value) {
20
22
  return false;
21
23
  return !Number.isNaN(Date.parse(value));
22
24
  }
25
+ function isProjectId(value) {
26
+ return typeof value === "string" && PROJECT_ID_PATTERN.test(value);
27
+ }
28
+ function isWorkspaceName(value) {
29
+ return (typeof value === "string" &&
30
+ value.trim().length > 0 &&
31
+ !/[\\/\r\n\t]/.test(value));
32
+ }
33
+ function slugifyIdentity(value) {
34
+ return value
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, "-")
38
+ .replace(/^-+|-+$/g, "");
39
+ }
23
40
  function relativePathSafe(filename) {
24
41
  if (path.isAbsolute(filename))
25
42
  return false;
@@ -29,6 +46,48 @@ function relativePathSafe(filename) {
29
46
  export function allowedKindExtensions() {
30
47
  return Object.fromEntries(Object.entries(KIND_EXTENSIONS).map(([kind, extensions]) => [kind, [...extensions]]));
31
48
  }
49
+ export function deriveManifestIdentity(repoRoot) {
50
+ const candidate = slugifyIdentity(path.basename(path.resolve(repoRoot)));
51
+ const identity = candidate || FALLBACK_WORKSPACE_ID;
52
+ return {
53
+ project_id: identity,
54
+ workspace_name: identity,
55
+ };
56
+ }
57
+ export function createManifest(repoRoot, now = new Date().toISOString()) {
58
+ return {
59
+ version: MANIFEST_VERSION,
60
+ created_at: now,
61
+ updated_at: now,
62
+ ...deriveManifestIdentity(repoRoot),
63
+ artifacts: [],
64
+ };
65
+ }
66
+ export function normalizeManifest(raw, repoRoot) {
67
+ const now = new Date().toISOString();
68
+ const defaults = deriveManifestIdentity(repoRoot);
69
+ if (Array.isArray(raw)) {
70
+ return {
71
+ version: 0,
72
+ created_at: now,
73
+ updated_at: now,
74
+ ...defaults,
75
+ artifacts: raw,
76
+ };
77
+ }
78
+ if (!isObject(raw)) {
79
+ return createManifest(repoRoot, now);
80
+ }
81
+ const artifacts = Array.isArray(raw.artifacts) ? raw.artifacts : [];
82
+ return {
83
+ version: typeof raw.version === "number" ? raw.version : MANIFEST_VERSION,
84
+ created_at: typeof raw.created_at === "string" ? raw.created_at : now,
85
+ updated_at: typeof raw.updated_at === "string" ? raw.updated_at : now,
86
+ 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,
88
+ artifacts,
89
+ };
90
+ }
32
91
  export function inferArtifactType(filePath) {
33
92
  const ext = path.extname(filePath).toLowerCase();
34
93
  if (ext === ".excalidraw")
@@ -121,6 +180,12 @@ export async function validateManifest(manifestPath, artifactsDir) {
121
180
  if (!isIsoDate(raw.updated_at)) {
122
181
  errors.push("updated_at must be an ISO timestamp");
123
182
  }
183
+ if (raw.project_id !== undefined && !isProjectId(raw.project_id)) {
184
+ errors.push("project_id must be a non-empty kebab-case string");
185
+ }
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");
188
+ }
124
189
  if (!Array.isArray(raw.artifacts)) {
125
190
  errors.push("artifacts must be an array");
126
191
  }
package/dist/storage.d.ts CHANGED
@@ -10,6 +10,10 @@ export declare class ReferencesStore {
10
10
  private readManifest;
11
11
  private writeManifest;
12
12
  listArtifacts(): Promise<Artifact[]>;
13
+ getWorkspaceIdentity(): Promise<{
14
+ project_id?: string;
15
+ workspace_name?: string;
16
+ }>;
13
17
  getArtifact(artifactId: string): Promise<Artifact | null>;
14
18
  getArtifactPath(artifact: Artifact): string;
15
19
  private slugify;
package/dist/storage.js CHANGED
@@ -2,14 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { promises as fs } from "node:fs";
4
4
  import path from "node:path";
5
- import { inferArtifactType, MANIFEST_VERSION, validateManifest } from "./manifest.js";
5
+ import { createManifest, inferArtifactType, normalizeManifest, validateManifest } from "./manifest.js";
6
6
  import { resolveRefsDir } from "./refs-paths.js";
7
7
  function utcNow() {
8
8
  return new Date().toISOString();
9
9
  }
10
- function isObject(value) {
11
- return typeof value === "object" && value !== null && !Array.isArray(value);
12
- }
13
10
  export class ReferencesStore {
14
11
  repoRoot;
15
12
  refsDir;
@@ -30,13 +27,7 @@ export class ReferencesStore {
30
27
  });
31
28
  }
32
29
  emptyManifest() {
33
- const now = utcNow();
34
- return {
35
- version: MANIFEST_VERSION,
36
- created_at: now,
37
- updated_at: now,
38
- artifacts: [],
39
- };
30
+ return createManifest(this.repoRoot, utcNow());
40
31
  }
41
32
  async readManifest() {
42
33
  let raw;
@@ -47,24 +38,7 @@ export class ReferencesStore {
47
38
  catch {
48
39
  return this.emptyManifest();
49
40
  }
50
- if (Array.isArray(raw)) {
51
- return {
52
- version: 0,
53
- created_at: utcNow(),
54
- updated_at: utcNow(),
55
- artifacts: raw,
56
- };
57
- }
58
- if (!isObject(raw)) {
59
- return this.emptyManifest();
60
- }
61
- const artifacts = Array.isArray(raw.artifacts) ? raw.artifacts : [];
62
- return {
63
- version: typeof raw.version === "number" ? raw.version : MANIFEST_VERSION,
64
- created_at: typeof raw.created_at === "string" ? raw.created_at : utcNow(),
65
- updated_at: typeof raw.updated_at === "string" ? raw.updated_at : utcNow(),
66
- artifacts,
67
- };
41
+ return normalizeManifest(raw, this.repoRoot);
68
42
  }
69
43
  async writeManifest(manifest) {
70
44
  await fs.writeFile(this.manifestPath, JSON.stringify(manifest, null, 2));
@@ -73,6 +47,13 @@ export class ReferencesStore {
73
47
  const manifest = await this.readManifest();
74
48
  return manifest.artifacts;
75
49
  }
50
+ async getWorkspaceIdentity() {
51
+ const manifest = await this.readManifest();
52
+ return {
53
+ project_id: manifest.project_id,
54
+ workspace_name: manifest.workspace_name,
55
+ };
56
+ }
76
57
  async getArtifact(artifactId) {
77
58
  const manifest = await this.readManifest();
78
59
  return manifest.artifacts.find((item) => item.id === artifactId) ?? null;
package/dist/types.d.ts CHANGED
@@ -16,5 +16,7 @@ export interface Manifest {
16
16
  version: number;
17
17
  created_at: string;
18
18
  updated_at: string;
19
+ project_id?: string;
20
+ workspace_name?: string;
19
21
  artifacts: Artifact[];
20
22
  }
package/dist/workspace.js CHANGED
@@ -1,5 +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
4
  import { DEFAULT_REFS_DIRNAME, LEGACY_REFS_DIRNAME, detectWorkspaceState, resolveCanonicalRefsDir, } from "./refs-paths.js";
4
5
  async function pathExists(targetPath) {
5
6
  try {
@@ -20,13 +21,28 @@ async function ensureCanonicalStructure(repoRoot) {
20
21
  }
21
22
  await fs.mkdir(artifactsDir, { recursive: true });
22
23
  if (!(await pathExists(manifestPath))) {
23
- const now = new Date().toISOString();
24
- await fs.writeFile(manifestPath, JSON.stringify({
25
- version: 1,
26
- created_at: now,
27
- updated_at: now,
28
- artifacts: [],
29
- }, null, 2), "utf8");
24
+ await fs.writeFile(manifestPath, JSON.stringify(normalizeManifest(undefined, repoRoot), null, 2), "utf8");
25
+ }
26
+ else {
27
+ const rawText = await fs.readFile(manifestPath, "utf8").catch(() => null);
28
+ if (rawText) {
29
+ try {
30
+ const raw = JSON.parse(rawText);
31
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
32
+ const record = raw;
33
+ const defaults = deriveManifestIdentity(repoRoot);
34
+ const missingIdentity = record.project_id === undefined || record.workspace_name === undefined;
35
+ if (missingIdentity) {
36
+ const nextManifest = normalizeManifest(raw, repoRoot);
37
+ nextManifest.updated_at = new Date().toISOString();
38
+ await fs.writeFile(manifestPath, JSON.stringify(nextManifest, null, 2), "utf8");
39
+ }
40
+ }
41
+ }
42
+ catch {
43
+ // Leave invalid manifests untouched so validation can report the problem explicitly.
44
+ }
45
+ }
30
46
  }
31
47
  return created;
32
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy-cli",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Planning system for AI assisted development.",
5
5
  "keywords": [
6
6
  "reffy",