djournal 0.1.0 → 0.2.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/.agents/adapters/shared/journal-hook.js +94 -11
- package/.agents/rules/AUTOMATION.md +2 -2
- package/.agents/rules/FEAT.md +9 -9
- package/.agents/rules/METADATA.md +2 -1
- package/.agents/rules/STATE.md +18 -5
- package/.agents/skills/audit/SKILL.md +4 -2
- package/.agents/skills/init-work/SKILL.md +6 -5
- package/.agents/skills/journal/SKILL.md +3 -2
- package/.agents/skills/plan/SKILL.md +3 -2
- package/.agents/skills/recall/SKILL.md +5 -3
- package/.agents/skills/resume/SKILL.md +2 -1
- package/.agents/skills/switch/SKILL.md +6 -5
- package/.claude/settings.json +4 -2
- package/AGENTS.md +4 -2
- package/README.md +155 -59
- package/bin/journal.js +20 -1
- package/docs/architecture.md +175 -0
- package/docs/djournal-in-practice.md +246 -0
- package/docs/installation.md +152 -0
- package/docs/remote-sync.md +144 -0
- package/docs/uninstalling.md +78 -0
- package/docs/visibility-and-sharing.md +88 -0
- package/lib/installer/index.js +458 -13
- package/lib/installer/merge.js +74 -0
- package/package.json +3 -1
package/lib/installer/index.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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 = [
|
|
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 =
|
|
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
|
|
433
|
+
throw new InstallerError(`managed JSON fragment was modified: ${relative}`, "ASSET_CONFLICT");
|
|
264
434
|
}
|
|
265
435
|
base = removed.value;
|
|
266
436
|
}
|
|
267
|
-
const merged =
|
|
437
|
+
const merged = mergeConfigFragment(base, fragment);
|
|
268
438
|
const injectedCount = Object.values(merged.injected).reduce((total, groups) => total + groups.length, 0);
|
|
269
|
-
const
|
|
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(
|
|
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,258 @@ 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 readJournalConfig(target) {
|
|
560
|
+
const context = projectContext(target);
|
|
561
|
+
return readProjectConfig(context);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function syncConfig(target) {
|
|
565
|
+
const config = readJournalConfig(target);
|
|
566
|
+
return config.sync && typeof config.sync === "object" ? config.sync : {};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function sharingConfig(config) {
|
|
570
|
+
return config.sharing && typeof config.sharing === "object" ? config.sharing : {};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function sharedWorkItems(config) {
|
|
574
|
+
const sharing = sharingConfig(config);
|
|
575
|
+
return sharing.sharedWorkItems && typeof sharing.sharedWorkItems === "object" ? sharing.sharedWorkItems : {};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function isWorkShared(config, slug) {
|
|
579
|
+
return Object.prototype.hasOwnProperty.call(sharedWorkItems(config), slug);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function parseConfigValue(value) {
|
|
583
|
+
if (value === "true") return true;
|
|
584
|
+
if (value === "false") return false;
|
|
585
|
+
if (value === "null") return null;
|
|
586
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
587
|
+
return value;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function setNested(value, keyPath, next) {
|
|
591
|
+
const parts = keyPath.split(".").filter(Boolean);
|
|
592
|
+
if (!parts.length) throw new InstallerError("config key is required", "USAGE");
|
|
593
|
+
const result = { ...value };
|
|
594
|
+
let cursor = result;
|
|
595
|
+
for (const part of parts.slice(0, -1)) {
|
|
596
|
+
const current = cursor[part];
|
|
597
|
+
cursor[part] = current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
|
|
598
|
+
cursor = cursor[part];
|
|
599
|
+
}
|
|
600
|
+
cursor[parts.at(-1)] = next;
|
|
601
|
+
return result;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function getNested(value, keyPath) {
|
|
605
|
+
if (!keyPath) return value;
|
|
606
|
+
let cursor = value;
|
|
607
|
+
for (const part of keyPath.split(".").filter(Boolean)) {
|
|
608
|
+
if (!cursor || typeof cursor !== "object" || !(part in cursor)) return undefined;
|
|
609
|
+
cursor = cursor[part];
|
|
610
|
+
}
|
|
611
|
+
return cursor;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function configure(options) {
|
|
615
|
+
const target = checkTarget(options.target || process.cwd());
|
|
616
|
+
const context = projectContext(target, options);
|
|
617
|
+
if (!context.global) throw new InstallerError("djournal project store is not initialized; run journal install first", "NOT_INSTALLED");
|
|
618
|
+
const current = readProjectConfig(context);
|
|
619
|
+
if (!options.key) return { action: "config", target, projectKey: context.projectKey, config: current };
|
|
620
|
+
if (typeof options.value === "undefined") {
|
|
621
|
+
return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(current, options.key) };
|
|
622
|
+
}
|
|
623
|
+
const next = setNested(current, options.key, parseConfigValue(options.value));
|
|
624
|
+
if (!options.dryRun) writeProjectConfig(context, next);
|
|
625
|
+
return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(next, options.key), changed: true, dryRun: !!options.dryRun };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function gitIgnored(target, relative, options = {}) {
|
|
629
|
+
const result = (options.runner || spawnSync)("git", ["-C", target, "check-ignore", "-q", relative], {
|
|
630
|
+
encoding: "utf8",
|
|
631
|
+
timeout: options.timeout || 30000,
|
|
632
|
+
});
|
|
633
|
+
if (result.error) return false;
|
|
634
|
+
return result.status === 0;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function share(options) {
|
|
638
|
+
const target = checkTarget(options.target || process.cwd());
|
|
639
|
+
const context = projectContext(target, options);
|
|
640
|
+
const config = readProjectConfig(context);
|
|
641
|
+
const work = readActiveWork(context, options.work);
|
|
642
|
+
const projectionRoot = projectionRootFor(target, config);
|
|
643
|
+
const ignored = config.sync?.mode === "colocated" ? gitIgnored(projectionRoot, `.journal/work/${work.slug}`, options) : false;
|
|
644
|
+
const warning = ignored
|
|
645
|
+
? `.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.`
|
|
646
|
+
: undefined;
|
|
647
|
+
const shared = sharedWorkItems(config);
|
|
648
|
+
if (Object.prototype.hasOwnProperty.call(shared, work.slug)) {
|
|
649
|
+
return { action: "share", target, projectKey: context.projectKey, work: work.slug, changed: false, shared: true, gitIgnored: ignored, warning };
|
|
650
|
+
}
|
|
651
|
+
const next = mergePlainObject(config, {
|
|
652
|
+
sharing: {
|
|
653
|
+
sharedWorkItems: {
|
|
654
|
+
...shared,
|
|
655
|
+
[work.slug]: {
|
|
656
|
+
sharedAt: new Date().toISOString(),
|
|
657
|
+
sharedBy: process.env.JOURNAL_USER_ID || runGitConfigEmail(options) || `${os.userInfo().username}@local`,
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
if (!options.dryRun) writeProjectConfig(context, next);
|
|
663
|
+
return { action: "share", target, projectKey: context.projectKey, work: work.slug, changed: true, shared: true, dryRun: !!options.dryRun, gitIgnored: ignored, warning };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function runGitConfigEmail(options = {}) {
|
|
667
|
+
const result = (options.runner || spawnSync)("git", ["config", "user.email"], {
|
|
668
|
+
encoding: "utf8",
|
|
669
|
+
timeout: options.timeout || 30000,
|
|
670
|
+
});
|
|
671
|
+
if (result.error || result.status !== 0) return "";
|
|
672
|
+
return (result.stdout || "").trim();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function runGit(gitRoot, args, options = {}) {
|
|
676
|
+
const result = (options.runner || spawnSync)("git", ["-C", gitRoot, ...args], {
|
|
677
|
+
encoding: "utf8",
|
|
678
|
+
timeout: options.timeout || 30000,
|
|
679
|
+
});
|
|
680
|
+
if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
|
|
681
|
+
if (result.status !== 0) {
|
|
682
|
+
const detail = `${result.stderr || ""}${result.stdout || ""}`.trim();
|
|
683
|
+
throw new InstallerError(detail || `git ${args.join(" ")} failed`, "GIT_ERROR");
|
|
684
|
+
}
|
|
685
|
+
return (result.stdout || "").trim();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function gitRootFor(directory, options = {}) {
|
|
689
|
+
const result = (options.runner || spawnSync)("git", ["-C", directory, "rev-parse", "--show-toplevel"], {
|
|
690
|
+
encoding: "utf8",
|
|
691
|
+
timeout: options.timeout || 30000,
|
|
692
|
+
});
|
|
693
|
+
if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
|
|
694
|
+
if (result.status !== 0) throw new InstallerError(".journal is not inside a Git work tree", "GIT_ERROR");
|
|
695
|
+
return (result.stdout || "").trim();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function journalPathspec(gitRoot, journalRoot) {
|
|
699
|
+
const relative = path.relative(gitRoot, journalRoot).split(path.sep).join("/");
|
|
700
|
+
return relative && !relative.startsWith("..") ? relative : ".";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function projectionRootFor(target, config) {
|
|
704
|
+
const sync = config.sync && typeof config.sync === "object" ? config.sync : {};
|
|
705
|
+
if (sync.path) return path.resolve(target, sync.path);
|
|
706
|
+
return path.resolve(target);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function projectJournalRoot(projectionRoot) {
|
|
710
|
+
return path.join(projectionRoot, ".journal");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function projectSharedWork(context, config, work, options = {}) {
|
|
714
|
+
const targetRoot = projectionRootFor(context.target, config);
|
|
715
|
+
const destinationJournal = projectJournalRoot(targetRoot);
|
|
716
|
+
const sourceWork = path.join(context.journalRoot, "work", work.slug);
|
|
717
|
+
const destinationWork = path.join(destinationJournal, "work", work.slug);
|
|
718
|
+
if (!fs.existsSync(sourceWork)) throw new InstallerError(`work item not found: ${work.slug}`, "INVALID_WORK");
|
|
719
|
+
const files = copyDirectoryContents(sourceWork, destinationWork, options).map((file) => `.journal/work/${work.slug}/${file}`);
|
|
720
|
+
return { projectionRoot: targetRoot, journalRoot: destinationJournal, files };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function bootstrapProjectStore(target, options = {}) {
|
|
724
|
+
const store = options.store || resolveProjectStore(target, options);
|
|
725
|
+
const existingConfig = readOptional(store.configFile);
|
|
726
|
+
const config = existingConfig
|
|
727
|
+
? mergePlainObject(defaultProjectConfig(target, store), parseJson(existingConfig, "config.json"))
|
|
728
|
+
: defaultProjectConfig(target, store);
|
|
729
|
+
const legacyConfig = readOptional(resolveWithin(target, ".journal/config.json"));
|
|
730
|
+
if (legacyConfig) {
|
|
731
|
+
const parsedLegacy = parseJson(legacyConfig, ".journal/config.json");
|
|
732
|
+
if (parsedLegacy.sync || parsedLegacy.sharing) {
|
|
733
|
+
config.sync = { ...config.sync, ...(parsedLegacy.sync || {}) };
|
|
734
|
+
config.sharing = mergePlainObject(config.sharing, parsedLegacy.sharing || {});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (!options.dryRun) {
|
|
738
|
+
fs.mkdirSync(store.journalRoot, { recursive: true });
|
|
739
|
+
copyDirectoryContents(resolveWithin(target, ".journal"), store.journalRoot, {
|
|
740
|
+
dryRun: false,
|
|
741
|
+
exclude: (relative) => relative === "config.json" || relative.startsWith(".install/"),
|
|
742
|
+
});
|
|
743
|
+
writeProjectConfig({ ...store, target, global: true }, config);
|
|
744
|
+
}
|
|
745
|
+
return { ...store, config };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function sync(options) {
|
|
749
|
+
const target = checkTarget(options.target || process.cwd());
|
|
750
|
+
const context = projectContext(target, options);
|
|
751
|
+
const config = readProjectConfig(context);
|
|
752
|
+
const syncOptions = config.sync && typeof config.sync === "object" ? config.sync : {};
|
|
753
|
+
if (syncOptions.enabled !== true) {
|
|
754
|
+
return { action: "sync", target, skipped: true, reason: "sync is not enabled" };
|
|
755
|
+
}
|
|
756
|
+
if (!["colocated", "standalone"].includes(syncOptions.mode)) {
|
|
757
|
+
return { action: "sync", target, skipped: true, reason: "sync mode is not standalone" };
|
|
758
|
+
}
|
|
759
|
+
const work = readActiveWork(context, options.work);
|
|
760
|
+
if (!isWorkShared(config, work.slug)) {
|
|
761
|
+
return { action: "sync", target, work: work.slug, skipped: true, reason: "work is not shared" };
|
|
762
|
+
}
|
|
763
|
+
const projection = projectSharedWork(context, config, work, options);
|
|
764
|
+
if (syncOptions.mode === "colocated") {
|
|
765
|
+
return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, files: projection.files, dryRun: !!options.dryRun };
|
|
766
|
+
}
|
|
767
|
+
const gitRoot = gitRootFor(projection.journalRoot, options);
|
|
768
|
+
const pathspec = journalPathspec(gitRoot, projection.journalRoot);
|
|
769
|
+
const unmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
|
|
770
|
+
if (unmerged) throw new InstallerError(`unresolved journal conflicts:\n${unmerged}`, "SYNC_CONFLICT");
|
|
771
|
+
if (options.dryRun) return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, dryRun: true };
|
|
772
|
+
|
|
773
|
+
runGit(gitRoot, ["pull", "--ff-only"], options);
|
|
774
|
+
const afterPullUnmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
|
|
775
|
+
if (afterPullUnmerged) throw new InstallerError(`unresolved journal conflicts:\n${afterPullUnmerged}`, "SYNC_CONFLICT");
|
|
776
|
+
runGit(gitRoot, ["add", "--", pathspec], options);
|
|
777
|
+
let committed = true;
|
|
778
|
+
try {
|
|
779
|
+
runGit(gitRoot, ["diff", "--cached", "--quiet", "--", pathspec], options);
|
|
780
|
+
committed = false;
|
|
781
|
+
} catch (error) {
|
|
782
|
+
if (!(error instanceof InstallerError) || error.code !== "GIT_ERROR") throw error;
|
|
783
|
+
runGit(gitRoot, ["commit", "-m", `chore(journal): sync ${work.slug}`], options);
|
|
784
|
+
}
|
|
785
|
+
runGit(gitRoot, ["push"], options);
|
|
786
|
+
return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, committed, pushed: true, auto: !!options.auto };
|
|
787
|
+
}
|
|
788
|
+
|
|
359
789
|
function doctor(options) {
|
|
360
790
|
const target = checkTarget(options.target || process.cwd());
|
|
361
791
|
const detected = detectHarnesses(target, options);
|
|
@@ -389,7 +819,8 @@ async function install(options) {
|
|
|
389
819
|
const detected = health.detected;
|
|
390
820
|
const selected = await selectHarnesses({ ...options, interactive: options.interactive ?? process.stdin.isTTY }, detected);
|
|
391
821
|
const harnesses = [...new Set([...(oldManifest?.harnesses || []), ...selected])].sort();
|
|
392
|
-
const
|
|
822
|
+
const store = bootstrapProjectStore(target, { ...options, store: resolveProjectStore(target, options) });
|
|
823
|
+
const planned = planInstall(sourceRoot, target, harnesses, oldManifest, store);
|
|
393
824
|
const manifest = {
|
|
394
825
|
schemaVersion: SCHEMA_VERSION,
|
|
395
826
|
toolVersion: packageVersion(sourceRoot),
|
|
@@ -398,6 +829,11 @@ async function install(options) {
|
|
|
398
829
|
harnesses,
|
|
399
830
|
files: planned.records.sort((a, b) => a.path.localeCompare(b.path)),
|
|
400
831
|
};
|
|
832
|
+
planned.operations.push(operation(target, PROJECT_MARKER_PATH, jsonBuffer({
|
|
833
|
+
schemaVersion: SCHEMA_VERSION,
|
|
834
|
+
projectKey: store.projectKey,
|
|
835
|
+
journalStore: store.root,
|
|
836
|
+
})));
|
|
401
837
|
planned.operations.push(operation(target, MANIFEST_PATH, jsonBuffer(manifest)));
|
|
402
838
|
applyTransaction(target, planned.operations, options);
|
|
403
839
|
if (!options.dryRun) {
|
|
@@ -440,7 +876,7 @@ function uninstallRecord(target, record, conflicts) {
|
|
|
440
876
|
const injectedCount = Object.values(record.injected || {}).reduce((total, groups) => total + groups.length, 0);
|
|
441
877
|
if (injectedCount === 0 && !record.created) return null;
|
|
442
878
|
const parsed = parseJson(current, record.path);
|
|
443
|
-
const removed =
|
|
879
|
+
const removed = removeConfigFragment(parsed, record.injected, record.injectedPermissions);
|
|
444
880
|
if (removed.conflicts.length) { conflicts.push(record.path); return null; }
|
|
445
881
|
const empty = Object.keys(removed.value).length === 0;
|
|
446
882
|
return operation(target, record.path, empty && record.created ? null : jsonBuffer(removed.value));
|
|
@@ -504,8 +940,11 @@ function recordStatus(target, record) {
|
|
|
504
940
|
if (record.mode === "json_merge") {
|
|
505
941
|
try {
|
|
506
942
|
const value = parseJson(current, record.path);
|
|
507
|
-
const
|
|
943
|
+
const hooksPresent = Object.entries(record.injected || {}).every(([event, groups]) =>
|
|
508
944
|
groups.every((group) => value.hooks?.[event]?.some((candidate) => equal(candidate, group))));
|
|
945
|
+
const permissionsPresent = Object.entries(record.injectedPermissions || {}).every(([key, values]) =>
|
|
946
|
+
values.every((item) => value.permissions?.[key]?.some((candidate) => equal(candidate, item))));
|
|
947
|
+
const present = hooksPresent && permissionsPresent;
|
|
509
948
|
return present ? (sha256(current) === record.installedHash ? "clean" : "modified") : "missing";
|
|
510
949
|
} catch { return "invalid"; }
|
|
511
950
|
}
|
|
@@ -523,15 +962,21 @@ function status(options) {
|
|
|
523
962
|
module.exports = {
|
|
524
963
|
InstallerError,
|
|
525
964
|
MANIFEST_PATH,
|
|
965
|
+
PROJECT_MARKER_PATH,
|
|
526
966
|
SUPPORTED_HARNESSES,
|
|
527
967
|
applyTransaction,
|
|
968
|
+
configure,
|
|
528
969
|
detectHarnesses,
|
|
529
970
|
doctor,
|
|
971
|
+
djournalHome,
|
|
530
972
|
install,
|
|
531
973
|
loadManifest,
|
|
974
|
+
projectContext,
|
|
532
975
|
recoverTransaction,
|
|
533
976
|
selectHarnesses,
|
|
977
|
+
share,
|
|
534
978
|
sourceInventory,
|
|
979
|
+
sync,
|
|
535
980
|
status,
|
|
536
981
|
uninstall,
|
|
537
982
|
upgrade,
|
package/lib/installer/merge.js
CHANGED
|
@@ -90,6 +90,47 @@ function mergeHookConfig(current, fragment) {
|
|
|
90
90
|
return { value: next, injected };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function appendUnique(target, values) {
|
|
94
|
+
const injected = [];
|
|
95
|
+
for (const value of values || []) {
|
|
96
|
+
if (!target.some((candidate) => equal(candidate, value))) {
|
|
97
|
+
const copy = structuredClone(value);
|
|
98
|
+
target.push(copy);
|
|
99
|
+
injected.push(copy);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return injected;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mergePermissionsConfig(current, fragment) {
|
|
106
|
+
const next = structuredClone(current);
|
|
107
|
+
const permissions = fragment.permissions || {};
|
|
108
|
+
const injected = {};
|
|
109
|
+
if (Array.isArray(permissions.additionalDirectories)) {
|
|
110
|
+
next.permissions ||= {};
|
|
111
|
+
next.permissions.additionalDirectories ||= [];
|
|
112
|
+
const additions = appendUnique(next.permissions.additionalDirectories, permissions.additionalDirectories);
|
|
113
|
+
if (additions.length) injected.additionalDirectories = additions;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(permissions.allow)) {
|
|
116
|
+
next.permissions ||= {};
|
|
117
|
+
next.permissions.allow ||= [];
|
|
118
|
+
const additions = appendUnique(next.permissions.allow, permissions.allow);
|
|
119
|
+
if (additions.length) injected.allow = additions;
|
|
120
|
+
}
|
|
121
|
+
return { value: next, injected };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mergeConfigFragment(current, fragment) {
|
|
125
|
+
const hook = mergeHookConfig(current, fragment);
|
|
126
|
+
const permissions = mergePermissionsConfig(hook.value, fragment);
|
|
127
|
+
return {
|
|
128
|
+
value: permissions.value,
|
|
129
|
+
injected: hook.injected,
|
|
130
|
+
injectedPermissions: permissions.injected,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
93
134
|
function removeHookConfig(current, injected) {
|
|
94
135
|
const next = structuredClone(current);
|
|
95
136
|
const conflicts = [];
|
|
@@ -110,6 +151,35 @@ function removeHookConfig(current, injected) {
|
|
|
110
151
|
return { value: next, conflicts };
|
|
111
152
|
}
|
|
112
153
|
|
|
154
|
+
function removePermissionsConfig(current, injected) {
|
|
155
|
+
const next = structuredClone(current);
|
|
156
|
+
const conflicts = [];
|
|
157
|
+
for (const [key, values] of Object.entries(injected || {})) {
|
|
158
|
+
const existing = next.permissions?.[key];
|
|
159
|
+
if (!Array.isArray(existing)) {
|
|
160
|
+
conflicts.push(`permissions.${key}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const value of values) {
|
|
164
|
+
const index = existing.findIndex((candidate) => equal(candidate, value));
|
|
165
|
+
if (index === -1) conflicts.push(`permissions.${key}`);
|
|
166
|
+
else existing.splice(index, 1);
|
|
167
|
+
}
|
|
168
|
+
if (existing.length === 0) delete next.permissions[key];
|
|
169
|
+
}
|
|
170
|
+
if (next.permissions && Object.keys(next.permissions).length === 0) delete next.permissions;
|
|
171
|
+
return { value: next, conflicts };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function removeConfigFragment(current, injected, injectedPermissions) {
|
|
175
|
+
const hook = removeHookConfig(current, injected);
|
|
176
|
+
const permissions = removePermissionsConfig(hook.value, injectedPermissions);
|
|
177
|
+
return {
|
|
178
|
+
value: permissions.value,
|
|
179
|
+
conflicts: [...hook.conflicts, ...permissions.conflicts],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
113
183
|
module.exports = {
|
|
114
184
|
BEGIN,
|
|
115
185
|
END,
|
|
@@ -118,8 +188,12 @@ module.exports = {
|
|
|
118
188
|
equal,
|
|
119
189
|
hasManagedBlock,
|
|
120
190
|
managedBlock,
|
|
191
|
+
mergeConfigFragment,
|
|
121
192
|
mergeHookConfig,
|
|
193
|
+
mergePermissionsConfig,
|
|
194
|
+
removeConfigFragment,
|
|
122
195
|
removeHookConfig,
|
|
196
|
+
removePermissionsConfig,
|
|
123
197
|
removeManagedBlock,
|
|
124
198
|
upsertManagedBlock,
|
|
125
199
|
};
|