djournal 0.1.0 → 0.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.
@@ -1,14 +1,16 @@
1
1
  "use strict";
2
2
 
3
3
  const crypto = require("node:crypto");
4
+ const { spawnSync } = require("node:child_process");
4
5
  const fs = require("node:fs");
6
+ const os = require("node:os");
5
7
  const path = require("node:path");
6
8
  const readline = require("node:readline/promises");
7
9
  const {
8
10
  equal,
9
11
  hasManagedBlock,
10
- mergeHookConfig,
11
- removeHookConfig,
12
+ mergeConfigFragment,
13
+ removeConfigFragment,
12
14
  removeManagedBlock,
13
15
  upsertManagedBlock,
14
16
  } = require("./merge.js");
@@ -18,6 +20,7 @@ const SUPPORTED_HARNESSES = ["codex", "claude-code"];
18
20
  const FUTURE_HARNESSES = ["opencode", "pi", "zed"];
19
21
  const MANIFEST_PATH = ".journal/.install/manifest.json";
20
22
  const TRANSACTION_PATH = ".journal/.install/transaction.json";
23
+ const PROJECT_MARKER_PATH = ".djournal.json";
21
24
 
22
25
  class InstallerError extends Error {
23
26
  constructor(message, code = "INSTALL_ERROR") {
@@ -43,10 +46,163 @@ function parseJson(buffer, label) {
43
46
  catch (error) { throw new InstallerError(`${label} is not valid JSON: ${error.message}`, "INVALID_JSON"); }
44
47
  }
45
48
 
49
+ function parseFrontmatter(text) {
50
+ if (!text.startsWith("---\n")) return {};
51
+ const end = text.indexOf("\n---", 4);
52
+ if (end === -1) return {};
53
+ const result = {};
54
+ for (const line of text.slice(4, end).split("\n")) {
55
+ const match = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/);
56
+ if (!match) continue;
57
+ result[match[1]] = match[2].trim().replace(/^"(.*)"$/, "$1");
58
+ }
59
+ return result;
60
+ }
61
+
62
+ function stringifyFrontmatterValue(value) {
63
+ return /[:#{}\[\],"]|\s/.test(value) ? JSON.stringify(value) : value;
64
+ }
65
+
66
+ function replaceFrontmatterField(text, field, value) {
67
+ if (!text.startsWith("---\n")) throw new InstallerError("work.md is missing frontmatter", "INVALID_JOURNAL");
68
+ const end = text.indexOf("\n---", 4);
69
+ if (end === -1) throw new InstallerError("work.md has unterminated frontmatter", "INVALID_JOURNAL");
70
+ const before = text.slice(0, end);
71
+ const after = text.slice(end);
72
+ const line = `${field}: ${stringifyFrontmatterValue(value)}`;
73
+ const pattern = new RegExp(`^${field}:.*$`, "m");
74
+ const next = pattern.test(before) ? before.replace(pattern, line) : `${before}\n${line}`;
75
+ return `${next}${after}`;
76
+ }
77
+
46
78
  function jsonBuffer(value) {
47
79
  return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
48
80
  }
49
81
 
82
+ function readJsonFile(file, label) {
83
+ return parseJson(readOptional(file), label);
84
+ }
85
+
86
+ function writeJsonFile(file, value) {
87
+ atomicWrite(file, jsonBuffer(value));
88
+ }
89
+
90
+ function mergePlainObject(base, patch) {
91
+ const result = { ...base };
92
+ for (const [key, value] of Object.entries(patch)) {
93
+ if (value && typeof value === "object" && !Array.isArray(value) && result[key] && typeof result[key] === "object" && !Array.isArray(result[key])) {
94
+ result[key] = mergePlainObject(result[key], value);
95
+ } else result[key] = value;
96
+ }
97
+ return result;
98
+ }
99
+
100
+ function djournalHome(options = {}) {
101
+ return path.resolve(options.djournalHome || process.env.DJOURNAL_HOME || path.join(os.homedir(), ".djournal"));
102
+ }
103
+
104
+ function projectKeyFor(target) {
105
+ const base = path.basename(path.resolve(target)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
106
+ return `${base}-${sha256(path.resolve(target)).slice(0, 8)}`;
107
+ }
108
+
109
+ function projectStoreFor(target, options = {}) {
110
+ const projectKey = options.projectKey || projectKeyFor(target);
111
+ const root = path.join(djournalHome(options), "projects", projectKey);
112
+ return { projectKey, root, journalRoot: path.join(root, ".journal"), configFile: path.join(root, "config.json") };
113
+ }
114
+
115
+ function resolveProjectStore(target, options = {}) {
116
+ return readProjectMarker(target) || projectStoreFor(target, options);
117
+ }
118
+
119
+ function readProjectMarker(target) {
120
+ const marker = readOptional(resolveWithin(target, PROJECT_MARKER_PATH));
121
+ if (!marker) return null;
122
+ const value = parseJson(marker, PROJECT_MARKER_PATH);
123
+ if (value.schemaVersion !== SCHEMA_VERSION) {
124
+ throw new InstallerError(`unsupported project marker schema: ${value.schemaVersion}`, "UNSUPPORTED_MARKER");
125
+ }
126
+ if (!value.projectKey || !value.journalStore) {
127
+ throw new InstallerError("project marker is missing projectKey or journalStore", "INVALID_JSON");
128
+ }
129
+ const root = path.resolve(value.journalStore);
130
+ return { projectKey: value.projectKey, root, journalRoot: path.join(root, ".journal"), configFile: path.join(root, "config.json") };
131
+ }
132
+
133
+ function projectContext(target, options = {}) {
134
+ const marker = readProjectMarker(target);
135
+ if (marker) return { ...marker, target, global: true };
136
+ const store = projectStoreFor(target, options);
137
+ if (fs.existsSync(store.configFile) || fs.existsSync(store.journalRoot)) return { ...store, target, global: true };
138
+ return {
139
+ target,
140
+ global: false,
141
+ projectKey: null,
142
+ root: target,
143
+ journalRoot: resolveWithin(target, ".journal"),
144
+ configFile: resolveWithin(target, ".journal/config.json"),
145
+ };
146
+ }
147
+
148
+ function defaultProjectConfig(target, store) {
149
+ return {
150
+ schemaVersion: SCHEMA_VERSION,
151
+ project: {
152
+ id: store.projectKey,
153
+ name: path.basename(target),
154
+ sourcePath: path.resolve(target),
155
+ },
156
+ sync: {
157
+ enabled: false,
158
+ mode: "colocated",
159
+ path: path.resolve(target),
160
+ auto: false,
161
+ },
162
+ sharing: {
163
+ default: "local_only",
164
+ sharedWorkItems: {},
165
+ },
166
+ };
167
+ }
168
+
169
+ function readProjectConfig(context) {
170
+ const config = readJsonFile(context.configFile, context.global ? "config.json" : ".journal/config.json");
171
+ if (!context.global) return config;
172
+ return mergePlainObject(defaultProjectConfig(context.target, context), config);
173
+ }
174
+
175
+ function writeProjectConfig(context, config) {
176
+ if (!context.global) writeJsonFile(context.configFile, config);
177
+ else writeJsonFile(context.configFile, config);
178
+ }
179
+
180
+ function copyDirectoryContents(source, destination, options = {}) {
181
+ if (!fs.existsSync(source)) return [];
182
+ if (fs.lstatSync(source).isSymbolicLink()) throw new InstallerError(`source is a symlink: ${source}`, "UNSAFE_PATH");
183
+ const copied = [];
184
+ for (const relative of listFiles(source)) {
185
+ if (options.exclude?.(relative)) continue;
186
+ const sourceFile = path.join(source, relative);
187
+ const destinationFile = path.join(destination, relative);
188
+ const destinationRoot = path.resolve(destination);
189
+ const resolved = path.resolve(destinationFile);
190
+ if (resolved !== destinationRoot && !resolved.startsWith(`${destinationRoot}${path.sep}`)) {
191
+ throw new InstallerError(`projection escapes target: ${relative}`, "UNSAFE_PATH");
192
+ }
193
+ if (fs.lstatSync(sourceFile).isSymbolicLink()) throw new InstallerError(`source file is a symlink: ${relative}`, "UNSAFE_PATH");
194
+ if (fs.existsSync(destinationFile) && fs.lstatSync(destinationFile).isSymbolicLink()) {
195
+ throw new InstallerError(`projection destination is a symlink: ${relative}`, "UNSAFE_PATH");
196
+ }
197
+ if (!options.dryRun) {
198
+ fs.mkdirSync(path.dirname(destinationFile), { recursive: true });
199
+ fs.copyFileSync(sourceFile, destinationFile);
200
+ }
201
+ copied.push(relative.split(path.sep).join("/"));
202
+ }
203
+ return copied;
204
+ }
205
+
50
206
  function resolveWithin(target, relative) {
51
207
  if (!relative || path.isAbsolute(relative)) {
52
208
  throw new InstallerError(`unsafe destination path: ${relative}`, "UNSAFE_PATH");
@@ -82,7 +238,7 @@ function listFiles(directory, prefix = "") {
82
238
  }
83
239
 
84
240
  function sourceInventory(sourceRoot) {
85
- const files = ["spec.md"];
241
+ const files = [];
86
242
  for (const directory of [".agents/rules", ".agents/skills", ".agents/adapters"]) {
87
243
  for (const relative of listFiles(path.join(sourceRoot, directory))) {
88
244
  files.push(`${directory}/${relative}`);
@@ -252,21 +408,36 @@ function markdownRecord(target, relative, body, oldRecord, harness) {
252
408
  };
253
409
  }
254
410
 
411
+ function journalAccessFragment(harness, store) {
412
+ if (harness !== "claude-code") return {};
413
+ const commands = ["status", "doctor", "config", "share", "sync"];
414
+ return {
415
+ permissions: {
416
+ additionalDirectories: [store.root],
417
+ allow: commands.flatMap((command) => [
418
+ `Bash(journal ${command}:*)`,
419
+ `Bash(djournal ${command}:*)`,
420
+ ]),
421
+ },
422
+ };
423
+ }
424
+
255
425
  function jsonRecord(target, relative, fragment, oldRecord, harness) {
256
426
  const absolute = resolveWithin(target, relative);
257
427
  const original = readOptional(absolute);
258
428
  const current = parseJson(original, relative);
259
429
  let base = current;
260
- if (oldRecord?.injected) {
261
- const removed = removeHookConfig(base, oldRecord.injected);
430
+ if (oldRecord?.injected || oldRecord?.injectedPermissions) {
431
+ const removed = removeConfigFragment(base, oldRecord.injected, oldRecord.injectedPermissions);
262
432
  if (removed.conflicts.length) {
263
- throw new InstallerError(`managed hook group was modified: ${relative}`, "ASSET_CONFLICT");
433
+ throw new InstallerError(`managed JSON fragment was modified: ${relative}`, "ASSET_CONFLICT");
264
434
  }
265
435
  base = removed.value;
266
436
  }
267
- const merged = mergeHookConfig(base, fragment);
437
+ const merged = mergeConfigFragment(base, fragment);
268
438
  const injectedCount = Object.values(merged.injected).reduce((total, groups) => total + groups.length, 0);
269
- const next = injectedCount === 0 && original !== null ? original : jsonBuffer(merged.value);
439
+ const injectedPermissionCount = Object.values(merged.injectedPermissions).reduce((total, values) => total + values.length, 0);
440
+ const next = injectedCount === 0 && injectedPermissionCount === 0 && original !== null ? original : jsonBuffer(merged.value);
270
441
  return {
271
442
  op: operation(target, relative, next),
272
443
  record: {
@@ -276,6 +447,7 @@ function jsonRecord(target, relative, fragment, oldRecord, harness) {
276
447
  created: oldRecord?.created ?? original === null,
277
448
  installedHash: sha256(next),
278
449
  injected: merged.injected,
450
+ injectedPermissions: merged.injectedPermissions,
279
451
  },
280
452
  };
281
453
  }
@@ -307,7 +479,7 @@ function harnessFragment(sourceRoot, harness) {
307
479
  return parseJson(fs.readFileSync(path.join(sourceRoot, relative)), relative);
308
480
  }
309
481
 
310
- function planInstall(sourceRoot, target, harnesses, oldManifest) {
482
+ function planInstall(sourceRoot, target, harnesses, oldManifest, store) {
311
483
  const old = recordMap(oldManifest);
312
484
  const records = [];
313
485
  const operations = [];
@@ -326,7 +498,13 @@ function planInstall(sourceRoot, target, harnesses, oldManifest) {
326
498
  if (harnesses.includes("claude-code")) {
327
499
  const bridge = markdownRecord(target, "CLAUDE.md", "@AGENTS.md", old.get("CLAUDE.md"), "claude-code");
328
500
  records.push(bridge.record); operations.push(bridge.op);
329
- const hooks = jsonRecord(target, ".claude/settings.json", harnessFragment(sourceRoot, "claude-code"), old.get(".claude/settings.json"), "claude-code");
501
+ const hooks = jsonRecord(
502
+ target,
503
+ ".claude/settings.json",
504
+ mergePlainObject(harnessFragment(sourceRoot, "claude-code"), journalAccessFragment("claude-code", store)),
505
+ old.get(".claude/settings.json"),
506
+ "claude-code",
507
+ );
330
508
  records.push(hooks.record); operations.push(hooks.op);
331
509
  }
332
510
 
@@ -356,6 +534,285 @@ function checkTarget(target) {
356
534
  return resolved;
357
535
  }
358
536
 
537
+ function readActiveWork(context, requested) {
538
+ const journalRoot = context.journalRoot;
539
+ let slug = requested;
540
+ if (!slug) {
541
+ const state = parseJson(readOptional(path.join(journalRoot, "state.json")), ".journal/state.json");
542
+ slug = state.active_work_name;
543
+ }
544
+ if (typeof slug !== "string" || !slug) {
545
+ throw new InstallerError("No active work found. Run init-work or switch first.", "NO_ACTIVE_WORK");
546
+ }
547
+ if (slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") {
548
+ throw new InstallerError(`invalid work slug: ${slug}`, "INVALID_WORK");
549
+ }
550
+ const relative = `.journal/work/${slug}/work.md`;
551
+ const file = path.join(journalRoot, "work", slug, "work.md");
552
+ const buffer = readOptional(file);
553
+ if (!buffer) throw new InstallerError(`work item not found: ${slug}`, "INVALID_WORK");
554
+ const text = buffer.toString("utf8");
555
+ const metadata = parseFrontmatter(text);
556
+ return { slug, relative, file, text, metadata, visibility: metadata.visibility || "local_only" };
557
+ }
558
+
559
+ function readAllWork(context) {
560
+ const workRoot = path.join(context.journalRoot, "work");
561
+ const entries = fs.existsSync(workRoot) ? fs.readdirSync(workRoot, { withFileTypes: true }) : [];
562
+ const workItems = entries
563
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
564
+ .map((entry) => entry.name)
565
+ .filter((slug) => fs.existsSync(path.join(workRoot, slug, "work.md")))
566
+ .sort()
567
+ .map((slug) => readActiveWork(context, slug));
568
+ if (!workItems.length) throw new InstallerError("no journal work items found", "NO_ACTIVE_WORK");
569
+ return workItems;
570
+ }
571
+
572
+ function readJournalConfig(target) {
573
+ const context = projectContext(target);
574
+ return readProjectConfig(context);
575
+ }
576
+
577
+ function syncConfig(target) {
578
+ const config = readJournalConfig(target);
579
+ return config.sync && typeof config.sync === "object" ? config.sync : {};
580
+ }
581
+
582
+ function sharingConfig(config) {
583
+ return config.sharing && typeof config.sharing === "object" ? config.sharing : {};
584
+ }
585
+
586
+ function sharedWorkItems(config) {
587
+ const sharing = sharingConfig(config);
588
+ return sharing.sharedWorkItems && typeof sharing.sharedWorkItems === "object" ? sharing.sharedWorkItems : {};
589
+ }
590
+
591
+ function isWorkShared(config, slug) {
592
+ return Object.prototype.hasOwnProperty.call(sharedWorkItems(config), slug);
593
+ }
594
+
595
+ function parseConfigValue(value) {
596
+ if (value === "true") return true;
597
+ if (value === "false") return false;
598
+ if (value === "null") return null;
599
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
600
+ return value;
601
+ }
602
+
603
+ function setNested(value, keyPath, next) {
604
+ const parts = keyPath.split(".").filter(Boolean);
605
+ if (!parts.length) throw new InstallerError("config key is required", "USAGE");
606
+ const result = { ...value };
607
+ let cursor = result;
608
+ for (const part of parts.slice(0, -1)) {
609
+ const current = cursor[part];
610
+ cursor[part] = current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
611
+ cursor = cursor[part];
612
+ }
613
+ cursor[parts.at(-1)] = next;
614
+ return result;
615
+ }
616
+
617
+ function getNested(value, keyPath) {
618
+ if (!keyPath) return value;
619
+ let cursor = value;
620
+ for (const part of keyPath.split(".").filter(Boolean)) {
621
+ if (!cursor || typeof cursor !== "object" || !(part in cursor)) return undefined;
622
+ cursor = cursor[part];
623
+ }
624
+ return cursor;
625
+ }
626
+
627
+ function configure(options) {
628
+ const target = checkTarget(options.target || process.cwd());
629
+ const context = projectContext(target, options);
630
+ if (!context.global) throw new InstallerError("djournal project store is not initialized; run journal install first", "NOT_INSTALLED");
631
+ const current = readProjectConfig(context);
632
+ if (!options.key) return { action: "config", target, projectKey: context.projectKey, config: current };
633
+ if (typeof options.value === "undefined") {
634
+ return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(current, options.key) };
635
+ }
636
+ const next = setNested(current, options.key, parseConfigValue(options.value));
637
+ if (!options.dryRun) writeProjectConfig(context, next);
638
+ return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(next, options.key), changed: true, dryRun: !!options.dryRun };
639
+ }
640
+
641
+ function gitIgnored(target, relative, options = {}) {
642
+ const result = (options.runner || spawnSync)("git", ["-C", target, "check-ignore", "-q", relative], {
643
+ encoding: "utf8",
644
+ timeout: options.timeout || 30000,
645
+ });
646
+ if (result.error) return false;
647
+ return result.status === 0;
648
+ }
649
+
650
+ function share(options) {
651
+ const target = checkTarget(options.target || process.cwd());
652
+ const context = projectContext(target, options);
653
+ const config = readProjectConfig(context);
654
+ const projectionRoot = projectionRootFor(target, config);
655
+ const shared = sharedWorkItems(config);
656
+ const workItems = options.all ? readAllWork(context) : [readActiveWork(context, options.work)];
657
+ const sharedAt = new Date().toISOString();
658
+ const sharedBy = process.env.JOURNAL_USER_ID || runGitConfigEmail(options) || `${os.userInfo().username}@local`;
659
+ const nextShared = { ...shared };
660
+ const results = workItems.map((work) => {
661
+ const ignored = config.sync?.mode === "colocated" ? gitIgnored(projectionRoot, `.journal/work/${work.slug}`, options) : false;
662
+ const warning = ignored
663
+ ? `.journal/work/${work.slug} appears to be ignored by Git; update .gitignore or force-add it before expecting colocated Git commits to share this work.`
664
+ : undefined;
665
+ const changed = !Object.prototype.hasOwnProperty.call(shared, work.slug);
666
+ if (changed) nextShared[work.slug] = { sharedAt, sharedBy };
667
+ return { work: work.slug, changed, shared: true, gitIgnored: ignored, warning };
668
+ });
669
+ const changed = results.some((result) => result.changed);
670
+ if (!options.all) {
671
+ const result = results[0];
672
+ if (!changed) return { action: "share", target, projectKey: context.projectKey, ...result };
673
+ }
674
+ const next = mergePlainObject(config, {
675
+ sharing: {
676
+ sharedWorkItems: nextShared,
677
+ },
678
+ });
679
+ if (changed && !options.dryRun) writeProjectConfig(context, next);
680
+ if (!options.all) return { action: "share", target, projectKey: context.projectKey, ...results[0], dryRun: !!options.dryRun };
681
+ return {
682
+ action: "share",
683
+ target,
684
+ projectKey: context.projectKey,
685
+ all: true,
686
+ changed,
687
+ shared: true,
688
+ workItems: results,
689
+ dryRun: !!options.dryRun,
690
+ };
691
+ }
692
+
693
+ function runGitConfigEmail(options = {}) {
694
+ const result = (options.runner || spawnSync)("git", ["config", "user.email"], {
695
+ encoding: "utf8",
696
+ timeout: options.timeout || 30000,
697
+ });
698
+ if (result.error || result.status !== 0) return "";
699
+ return (result.stdout || "").trim();
700
+ }
701
+
702
+ function runGit(gitRoot, args, options = {}) {
703
+ const result = (options.runner || spawnSync)("git", ["-C", gitRoot, ...args], {
704
+ encoding: "utf8",
705
+ timeout: options.timeout || 30000,
706
+ });
707
+ if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
708
+ if (result.status !== 0) {
709
+ const detail = `${result.stderr || ""}${result.stdout || ""}`.trim();
710
+ throw new InstallerError(detail || `git ${args.join(" ")} failed`, "GIT_ERROR");
711
+ }
712
+ return (result.stdout || "").trim();
713
+ }
714
+
715
+ function gitRootFor(directory, options = {}) {
716
+ const result = (options.runner || spawnSync)("git", ["-C", directory, "rev-parse", "--show-toplevel"], {
717
+ encoding: "utf8",
718
+ timeout: options.timeout || 30000,
719
+ });
720
+ if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
721
+ if (result.status !== 0) throw new InstallerError(".journal is not inside a Git work tree", "GIT_ERROR");
722
+ return (result.stdout || "").trim();
723
+ }
724
+
725
+ function journalPathspec(gitRoot, journalRoot) {
726
+ const relative = path.relative(gitRoot, journalRoot).split(path.sep).join("/");
727
+ return relative && !relative.startsWith("..") ? relative : ".";
728
+ }
729
+
730
+ function projectionRootFor(target, config) {
731
+ const sync = config.sync && typeof config.sync === "object" ? config.sync : {};
732
+ if (sync.path) return path.resolve(target, sync.path);
733
+ return path.resolve(target);
734
+ }
735
+
736
+ function projectJournalRoot(projectionRoot) {
737
+ return path.join(projectionRoot, ".journal");
738
+ }
739
+
740
+ function projectSharedWork(context, config, work, options = {}) {
741
+ const targetRoot = projectionRootFor(context.target, config);
742
+ const destinationJournal = projectJournalRoot(targetRoot);
743
+ const sourceWork = path.join(context.journalRoot, "work", work.slug);
744
+ const destinationWork = path.join(destinationJournal, "work", work.slug);
745
+ if (!fs.existsSync(sourceWork)) throw new InstallerError(`work item not found: ${work.slug}`, "INVALID_WORK");
746
+ const files = copyDirectoryContents(sourceWork, destinationWork, options).map((file) => `.journal/work/${work.slug}/${file}`);
747
+ return { projectionRoot: targetRoot, journalRoot: destinationJournal, files };
748
+ }
749
+
750
+ function bootstrapProjectStore(target, options = {}) {
751
+ const store = options.store || resolveProjectStore(target, options);
752
+ const existingConfig = readOptional(store.configFile);
753
+ const config = existingConfig
754
+ ? mergePlainObject(defaultProjectConfig(target, store), parseJson(existingConfig, "config.json"))
755
+ : defaultProjectConfig(target, store);
756
+ const legacyConfig = readOptional(resolveWithin(target, ".journal/config.json"));
757
+ if (legacyConfig) {
758
+ const parsedLegacy = parseJson(legacyConfig, ".journal/config.json");
759
+ if (parsedLegacy.sync || parsedLegacy.sharing) {
760
+ config.sync = { ...config.sync, ...(parsedLegacy.sync || {}) };
761
+ config.sharing = mergePlainObject(config.sharing, parsedLegacy.sharing || {});
762
+ }
763
+ }
764
+ if (!options.dryRun) {
765
+ fs.mkdirSync(store.journalRoot, { recursive: true });
766
+ copyDirectoryContents(resolveWithin(target, ".journal"), store.journalRoot, {
767
+ dryRun: false,
768
+ exclude: (relative) => relative === "config.json" || relative.startsWith(".install/"),
769
+ });
770
+ writeProjectConfig({ ...store, target, global: true }, config);
771
+ }
772
+ return { ...store, config };
773
+ }
774
+
775
+ function sync(options) {
776
+ const target = checkTarget(options.target || process.cwd());
777
+ const context = projectContext(target, options);
778
+ const config = readProjectConfig(context);
779
+ const syncOptions = config.sync && typeof config.sync === "object" ? config.sync : {};
780
+ if (syncOptions.enabled !== true) {
781
+ return { action: "sync", target, skipped: true, reason: "sync is not enabled" };
782
+ }
783
+ if (!["colocated", "standalone"].includes(syncOptions.mode)) {
784
+ return { action: "sync", target, skipped: true, reason: "sync mode is not standalone" };
785
+ }
786
+ const work = readActiveWork(context, options.work);
787
+ if (!isWorkShared(config, work.slug)) {
788
+ return { action: "sync", target, work: work.slug, skipped: true, reason: "work is not shared" };
789
+ }
790
+ const projection = projectSharedWork(context, config, work, options);
791
+ if (syncOptions.mode === "colocated") {
792
+ return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, files: projection.files, dryRun: !!options.dryRun };
793
+ }
794
+ const gitRoot = gitRootFor(projection.journalRoot, options);
795
+ const pathspec = journalPathspec(gitRoot, projection.journalRoot);
796
+ const unmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
797
+ if (unmerged) throw new InstallerError(`unresolved journal conflicts:\n${unmerged}`, "SYNC_CONFLICT");
798
+ if (options.dryRun) return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, dryRun: true };
799
+
800
+ runGit(gitRoot, ["pull", "--ff-only"], options);
801
+ const afterPullUnmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
802
+ if (afterPullUnmerged) throw new InstallerError(`unresolved journal conflicts:\n${afterPullUnmerged}`, "SYNC_CONFLICT");
803
+ runGit(gitRoot, ["add", "--", pathspec], options);
804
+ let committed = true;
805
+ try {
806
+ runGit(gitRoot, ["diff", "--cached", "--quiet", "--", pathspec], options);
807
+ committed = false;
808
+ } catch (error) {
809
+ if (!(error instanceof InstallerError) || error.code !== "GIT_ERROR") throw error;
810
+ runGit(gitRoot, ["commit", "-m", `chore(journal): sync ${work.slug}`], options);
811
+ }
812
+ runGit(gitRoot, ["push"], options);
813
+ return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, committed, pushed: true, auto: !!options.auto };
814
+ }
815
+
359
816
  function doctor(options) {
360
817
  const target = checkTarget(options.target || process.cwd());
361
818
  const detected = detectHarnesses(target, options);
@@ -389,7 +846,8 @@ async function install(options) {
389
846
  const detected = health.detected;
390
847
  const selected = await selectHarnesses({ ...options, interactive: options.interactive ?? process.stdin.isTTY }, detected);
391
848
  const harnesses = [...new Set([...(oldManifest?.harnesses || []), ...selected])].sort();
392
- const planned = planInstall(sourceRoot, target, harnesses, oldManifest);
849
+ const store = bootstrapProjectStore(target, { ...options, store: resolveProjectStore(target, options) });
850
+ const planned = planInstall(sourceRoot, target, harnesses, oldManifest, store);
393
851
  const manifest = {
394
852
  schemaVersion: SCHEMA_VERSION,
395
853
  toolVersion: packageVersion(sourceRoot),
@@ -398,6 +856,11 @@ async function install(options) {
398
856
  harnesses,
399
857
  files: planned.records.sort((a, b) => a.path.localeCompare(b.path)),
400
858
  };
859
+ planned.operations.push(operation(target, PROJECT_MARKER_PATH, jsonBuffer({
860
+ schemaVersion: SCHEMA_VERSION,
861
+ projectKey: store.projectKey,
862
+ journalStore: store.root,
863
+ })));
401
864
  planned.operations.push(operation(target, MANIFEST_PATH, jsonBuffer(manifest)));
402
865
  applyTransaction(target, planned.operations, options);
403
866
  if (!options.dryRun) {
@@ -440,7 +903,7 @@ function uninstallRecord(target, record, conflicts) {
440
903
  const injectedCount = Object.values(record.injected || {}).reduce((total, groups) => total + groups.length, 0);
441
904
  if (injectedCount === 0 && !record.created) return null;
442
905
  const parsed = parseJson(current, record.path);
443
- const removed = removeHookConfig(parsed, record.injected);
906
+ const removed = removeConfigFragment(parsed, record.injected, record.injectedPermissions);
444
907
  if (removed.conflicts.length) { conflicts.push(record.path); return null; }
445
908
  const empty = Object.keys(removed.value).length === 0;
446
909
  return operation(target, record.path, empty && record.created ? null : jsonBuffer(removed.value));
@@ -504,8 +967,11 @@ function recordStatus(target, record) {
504
967
  if (record.mode === "json_merge") {
505
968
  try {
506
969
  const value = parseJson(current, record.path);
507
- const present = Object.entries(record.injected || {}).every(([event, groups]) =>
970
+ const hooksPresent = Object.entries(record.injected || {}).every(([event, groups]) =>
508
971
  groups.every((group) => value.hooks?.[event]?.some((candidate) => equal(candidate, group))));
972
+ const permissionsPresent = Object.entries(record.injectedPermissions || {}).every(([key, values]) =>
973
+ values.every((item) => value.permissions?.[key]?.some((candidate) => equal(candidate, item))));
974
+ const present = hooksPresent && permissionsPresent;
509
975
  return present ? (sha256(current) === record.installedHash ? "clean" : "modified") : "missing";
510
976
  } catch { return "invalid"; }
511
977
  }
@@ -523,15 +989,21 @@ function status(options) {
523
989
  module.exports = {
524
990
  InstallerError,
525
991
  MANIFEST_PATH,
992
+ PROJECT_MARKER_PATH,
526
993
  SUPPORTED_HARNESSES,
527
994
  applyTransaction,
995
+ configure,
528
996
  detectHarnesses,
529
997
  doctor,
998
+ djournalHome,
530
999
  install,
531
1000
  loadManifest,
1001
+ projectContext,
532
1002
  recoverTransaction,
533
1003
  selectHarnesses,
1004
+ share,
534
1005
  sourceInventory,
1006
+ sync,
535
1007
  status,
536
1008
  uninstall,
537
1009
  upgrade,