@bigking67/pi-67 0.10.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +182 -0
  3. package/bin/pi-67.mjs +13 -0
  4. package/package.json +47 -0
  5. package/schemas/pi67-distro-manifest.schema.json +130 -0
  6. package/schemas/pi67-extension-registry.schema.json +110 -0
  7. package/schemas/pi67-publish-check.schema.json +122 -0
  8. package/schemas/pi67-state.schema.json +31 -0
  9. package/schemas/pi67-update-plan.schema.json +141 -0
  10. package/scripts/check.mjs +401 -0
  11. package/src/cli.mjs +108 -0
  12. package/src/commands/backups.mjs +124 -0
  13. package/src/commands/doctor.mjs +21 -0
  14. package/src/commands/extensions.mjs +184 -0
  15. package/src/commands/external.mjs +63 -0
  16. package/src/commands/install.mjs +48 -0
  17. package/src/commands/manifest.mjs +74 -0
  18. package/src/commands/publish-check.mjs +366 -0
  19. package/src/commands/report.mjs +20 -0
  20. package/src/commands/self-update.mjs +16 -0
  21. package/src/commands/skills.mjs +49 -0
  22. package/src/commands/smoke.mjs +18 -0
  23. package/src/commands/status.mjs +21 -0
  24. package/src/commands/themes.mjs +69 -0
  25. package/src/commands/update.mjs +138 -0
  26. package/src/commands/version.mjs +51 -0
  27. package/src/commands/xtalpi.mjs +69 -0
  28. package/src/data/distro-manifest.json +85 -0
  29. package/src/data/extension-registry.json +147 -0
  30. package/src/lib/args.mjs +87 -0
  31. package/src/lib/config-json.mjs +20 -0
  32. package/src/lib/distro-manifest.mjs +131 -0
  33. package/src/lib/distro-scripts.mjs +27 -0
  34. package/src/lib/extension-registry.mjs +250 -0
  35. package/src/lib/external-repos.mjs +71 -0
  36. package/src/lib/git.mjs +55 -0
  37. package/src/lib/npm-registry.mjs +288 -0
  38. package/src/lib/output.mjs +35 -0
  39. package/src/lib/paths.mjs +79 -0
  40. package/src/lib/platform.mjs +27 -0
  41. package/src/lib/shell-runner.mjs +40 -0
  42. package/src/lib/skill-policy.mjs +85 -0
  43. package/src/lib/state-store.mjs +31 -0
  44. package/src/lib/theme-policy.mjs +34 -0
  45. package/src/lib/update-plan.mjs +312 -0
  46. package/src/lib/update-safety.mjs +310 -0
@@ -0,0 +1,131 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { EXTERNAL_REPOS } from "./external-repos.mjs";
4
+ import { readJsonFileIfExists } from "./config-json.mjs";
5
+ import { packageRoot } from "./paths.mjs";
6
+ import { readExtensionRegistry } from "./extension-registry.mjs";
7
+
8
+ export function buildDistroManifest(ctx) {
9
+ const base = readBaseManifest();
10
+ const extensionRegistry = readExtensionRegistry();
11
+ const rootPackage = readJsonFileIfExists(path.join(ctx.repoRoot, "package.json")) || {};
12
+ const settings = readJsonFileIfExists(path.join(ctx.agentDir, "settings.json")) || {};
13
+ const dependencies = rootPackage.dependencies || {};
14
+ const dependencyPackages = Object.entries(dependencies)
15
+ .map(([packageName, versionRange]) => ({
16
+ spec: `npm:${packageName}`,
17
+ packageName,
18
+ versionRange,
19
+ owner: "pi67-managed",
20
+ source: "package.json.dependencies",
21
+ role: packageName === base.theme.packageName ? "theme-package" : "pi-package",
22
+ policy: packageName === base.theme.packageName
23
+ ? base.theme.policy
24
+ : "install-update-through-pi67-preserve-user-runtime-config",
25
+ }))
26
+ .sort((left, right) => left.packageName.localeCompare(right.packageName));
27
+
28
+ const dependencyNames = new Set(dependencyPackages.map((item) => item.packageName));
29
+ const knownRuntime = new Map((base.runtimePackageSpecs || []).map((item) => [item.spec, item]));
30
+ const knownRuntimeNames = new Set((base.runtimePackageSpecs || []).map((item) => item.packageName));
31
+ const settingsPackages = Array.isArray(settings.packages) ? settings.packages : [];
32
+ const runtimePackages = settingsPackages.map((spec) => {
33
+ const packageName = packageNameFromSpec(spec);
34
+ const known = knownRuntime.get(spec);
35
+ const isThemePackage = packageName === base.theme.packageName;
36
+ const managed = Boolean(known) || dependencyNames.has(packageName) || knownRuntimeNames.has(packageName);
37
+ return {
38
+ spec,
39
+ packageName,
40
+ owner: managed ? "pi67-managed" : "user-managed",
41
+ source: "settings.json.packages",
42
+ role: isThemePackage ? "theme-package" : "runtime-package",
43
+ dependencyManaged: dependencyNames.has(packageName),
44
+ policy: isThemePackage ? base.theme.policy : known?.policy || (managed
45
+ ? "preserve-user-config-and-repair-known-drift"
46
+ : "report-only-do-not-overwrite"),
47
+ };
48
+ });
49
+
50
+ const managedExtensionNames = new Set((base.localExtensions || []).map((item) => item.name));
51
+ const localExtensions = (base.localExtensions || []).map((item) => ({
52
+ ...item,
53
+ owner: "pi67-managed",
54
+ exists: fs.existsSync(path.join(ctx.repoRoot, item.path)),
55
+ })).concat(scanUserLocalExtensions(ctx, managedExtensionNames));
56
+ const userManagedPackages = runtimePackages.filter((item) => item.owner === "user-managed");
57
+
58
+ return {
59
+ schema: "pi67.distro-manifest.v1",
60
+ createdAt: new Date().toISOString(),
61
+ ownership: base.ownership,
62
+ commands: base.commands,
63
+ runtimeFiles: base.runtimeFiles,
64
+ theme: base.theme,
65
+ sharedSkills: {
66
+ ...base.sharedSkills,
67
+ activeDir: ctx.skillsDir,
68
+ },
69
+ externalReposPolicy: base.externalReposPolicy,
70
+ extensionRegistry,
71
+ localExtensions,
72
+ dependencyPackages,
73
+ runtimePackages,
74
+ externalRepos: Object.values(EXTERNAL_REPOS),
75
+ summary: {
76
+ dependencies: dependencyPackages.length,
77
+ runtimePackages: runtimePackages.length,
78
+ pi67ManagedRuntimePackages: runtimePackages.length - userManagedPackages.length,
79
+ userManagedRuntimePackages: userManagedPackages.length,
80
+ localExtensions: localExtensions.length,
81
+ missingLocalExtensions: localExtensions.filter((item) => !item.exists).length,
82
+ externalRepos: Object.keys(EXTERNAL_REPOS).length,
83
+ runtimeFilesPreserved: base.runtimeFiles.preserve.length,
84
+ registeredExtensions: extensionRegistry.extensions.length,
85
+ },
86
+ userManagedPackages,
87
+ };
88
+ }
89
+
90
+ export function packageNameFromSpec(spec) {
91
+ const value = String(spec || "");
92
+ if (value.startsWith("npm:")) return npmPackageName(value.slice(4));
93
+ if (value.startsWith("git:")) {
94
+ const repo = value.split("/").pop() || value;
95
+ return repo.replace(/\.git$/, "");
96
+ }
97
+ if (value.startsWith("local:")) return path.basename(value);
98
+ return value;
99
+ }
100
+
101
+ function npmPackageName(value) {
102
+ if (value.startsWith("@")) {
103
+ const slash = value.indexOf("/");
104
+ if (slash === -1) return value;
105
+ const versionAt = value.indexOf("@", slash + 1);
106
+ return versionAt === -1 ? value : value.slice(0, versionAt);
107
+ }
108
+ const versionAt = value.indexOf("@");
109
+ return versionAt === -1 ? value : value.slice(0, versionAt);
110
+ }
111
+
112
+ function scanUserLocalExtensions(ctx, managedNames) {
113
+ const extensionsRoot = path.join(ctx.repoRoot, "extensions");
114
+ if (!fs.existsSync(extensionsRoot)) return [];
115
+ return fs.readdirSync(extensionsRoot, { withFileTypes: true })
116
+ .filter((entry) => entry.isDirectory() && !managedNames.has(entry.name))
117
+ .map((entry) => ({
118
+ name: entry.name,
119
+ path: path.join("extensions", entry.name),
120
+ required: false,
121
+ owner: "user-managed",
122
+ policy: "report-only-do-not-overwrite",
123
+ exists: true,
124
+ }))
125
+ .sort((left, right) => left.name.localeCompare(right.name));
126
+ }
127
+
128
+ function readBaseManifest() {
129
+ const file = path.join(packageRoot(), "src", "data", "distro-manifest.json");
130
+ return JSON.parse(fs.readFileSync(file, "utf8"));
131
+ }
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs";
2
+ import { scriptPath } from "./paths.mjs";
3
+ import { isWindows, findPowerShell } from "./platform.mjs";
4
+ import { runCommand } from "./shell-runner.mjs";
5
+ import { CliError } from "./output.mjs";
6
+
7
+ export function runDistroScript(ctx, names, args = [], options = {}) {
8
+ const script = isWindows() ? names.ps1 : names.sh;
9
+ const file = scriptPath(ctx, script);
10
+ if (!fs.existsSync(file)) {
11
+ throw new CliError(`missing pi-67 script: ${file}`);
12
+ }
13
+ if (isWindows()) {
14
+ const pwsh = findPowerShell();
15
+ if (!pwsh) throw new CliError("PowerShell executable not found");
16
+ return runCommand(pwsh, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", file, ...args], {
17
+ cwd: ctx.repoRoot,
18
+ dryRun: options.dryRun,
19
+ env: options.env,
20
+ });
21
+ }
22
+ return runCommand("bash", [file, ...args], {
23
+ cwd: ctx.repoRoot,
24
+ dryRun: options.dryRun,
25
+ env: options.env,
26
+ });
27
+ }
@@ -0,0 +1,250 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { packageRoot } from "./paths.mjs";
4
+
5
+ export const REQUIRED_EXTENSION_REGISTRY_IDS = [
6
+ "xtalpi-pi-tools",
7
+ "pi-rules-loader",
8
+ "pi-curated-themes",
9
+ "shared-skills",
10
+ "browser67",
11
+ "design-craft",
12
+ ];
13
+
14
+ const REQUIRED_TOP_LEVEL = ["schema", "governance", "extensions"];
15
+ const REQUIRED_ENTRY_FIELDS = [
16
+ "id",
17
+ "kind",
18
+ "owner",
19
+ "installStrategy",
20
+ "updateStrategy",
21
+ "repairStrategy",
22
+ "configPatches",
23
+ "smoke",
24
+ ];
25
+ const REQUIRED_PATCH_FIELDS = ["file", "path", "mode"];
26
+ const STRATEGY_FIELDS = ["installStrategy", "updateStrategy", "repairStrategy"];
27
+
28
+ export function readExtensionRegistry(file = defaultExtensionRegistryFile()) {
29
+ return JSON.parse(fs.readFileSync(file, "utf8"));
30
+ }
31
+
32
+ export function defaultExtensionRegistryFile() {
33
+ return path.join(packageRoot(), "src", "data", "extension-registry.json");
34
+ }
35
+
36
+ export function validateExtensionRegistry(registry, options = {}) {
37
+ const manifest = options.manifest || {};
38
+ const requiredIds = options.requiredIds || REQUIRED_EXTENSION_REGISTRY_IDS;
39
+ const problems = [];
40
+ const warnings = [];
41
+
42
+ if (!isPlainObject(registry)) {
43
+ return {
44
+ ok: false,
45
+ message: "extension registry must be an object",
46
+ problems: ["extension registry must be an object"],
47
+ warnings,
48
+ summary: emptySummary(),
49
+ };
50
+ }
51
+
52
+ for (const field of REQUIRED_TOP_LEVEL) {
53
+ if (!(field in registry)) problems.push(`extension registry missing ${field}`);
54
+ }
55
+ if (registry.schema !== "pi67.extension-registry.v1") {
56
+ problems.push(`extension registry schema must be pi67.extension-registry.v1, got ${registry.schema || "missing"}`);
57
+ }
58
+
59
+ const governance = isPlainObject(registry.governance) ? registry.governance : {};
60
+ if (!isPlainObject(registry.governance)) {
61
+ problems.push("extension registry governance must be an object");
62
+ }
63
+ const allowedPatchModes = new Set(arrayOfStrings(governance.configPatchModes));
64
+ const forbiddenFragments = arrayOfStrings(governance.forbiddenUpdateBehavior);
65
+ if (allowedPatchModes.size === 0) {
66
+ problems.push("extension registry governance must declare configPatchModes");
67
+ }
68
+ if (forbiddenFragments.length === 0) {
69
+ problems.push("extension registry governance must declare forbiddenUpdateBehavior");
70
+ }
71
+
72
+ const entries = Array.isArray(registry.extensions) ? registry.extensions : [];
73
+ if (!Array.isArray(registry.extensions)) {
74
+ problems.push("extension registry extensions must be an array");
75
+ }
76
+
77
+ const ids = new Set();
78
+ const duplicateIds = [];
79
+ for (const [index, entry] of entries.entries()) {
80
+ const label = entryLabel(entry, index);
81
+ if (!isPlainObject(entry)) {
82
+ problems.push(`${label} must be an object`);
83
+ continue;
84
+ }
85
+ for (const field of REQUIRED_ENTRY_FIELDS) {
86
+ if (!(field in entry)) problems.push(`${label} missing ${field}`);
87
+ }
88
+ if (typeof entry.id === "string" && entry.id.length > 0) {
89
+ if (ids.has(entry.id)) duplicateIds.push(entry.id);
90
+ ids.add(entry.id);
91
+ } else {
92
+ problems.push(`${label} id must be a non-empty string`);
93
+ }
94
+ for (const field of STRATEGY_FIELDS) {
95
+ if (typeof entry[field] !== "string" || entry[field].length === 0) {
96
+ problems.push(`${label} ${field} must be a non-empty string`);
97
+ }
98
+ }
99
+ if (!Array.isArray(entry.configPatches)) {
100
+ problems.push(`${label} configPatches must be an array`);
101
+ } else {
102
+ validateConfigPatches(entry, label, allowedPatchModes, problems);
103
+ }
104
+ if (!Array.isArray(entry.smoke) || entry.smoke.length === 0) {
105
+ problems.push(`${label} must declare at least one smoke gate`);
106
+ } else if (entry.smoke.some((item) => typeof item !== "string" || item.length === 0)) {
107
+ problems.push(`${label} smoke gates must be non-empty strings`);
108
+ }
109
+ validateForbiddenStrategyText(entry, label, forbiddenFragments, problems);
110
+ }
111
+
112
+ for (const id of duplicateIds) {
113
+ problems.push(`duplicate extension registry id: ${id}`);
114
+ }
115
+ for (const id of requiredIds) {
116
+ if (!ids.has(id)) problems.push(`missing extension registry entry: ${id}`);
117
+ }
118
+
119
+ validateManifestParity(entries, manifest, problems, warnings);
120
+
121
+ const summary = {
122
+ entries: entries.length,
123
+ requiredEntries: requiredIds.length,
124
+ duplicateIds: duplicateIds.length,
125
+ smokeGates: entries.reduce((count, entry) => count + (Array.isArray(entry?.smoke) ? entry.smoke.length : 0), 0),
126
+ configPatches: entries.reduce((count, entry) => count + (Array.isArray(entry?.configPatches) ? entry.configPatches.length : 0), 0),
127
+ };
128
+
129
+ return {
130
+ ok: problems.length === 0,
131
+ message: problems.length === 0 ? "extension registry policy ready" : problems.join("; "),
132
+ problems,
133
+ warnings,
134
+ summary,
135
+ };
136
+ }
137
+
138
+ function validateConfigPatches(entry, label, allowedPatchModes, problems) {
139
+ for (const [index, patch] of entry.configPatches.entries()) {
140
+ const patchLabel = `${label} configPatches[${index}]`;
141
+ if (!isPlainObject(patch)) {
142
+ problems.push(`${patchLabel} must be an object`);
143
+ continue;
144
+ }
145
+ for (const field of REQUIRED_PATCH_FIELDS) {
146
+ if (!(field in patch)) problems.push(`${patchLabel} missing ${field}`);
147
+ }
148
+ if (!allowedPatchModes.has(patch.mode)) {
149
+ problems.push(`${label} has unsupported config patch mode: ${patch.mode || "missing"}`);
150
+ }
151
+ if (!isSafePatchMode(patch)) {
152
+ problems.push(`${label} config patch for ${patch.file || "unknown"} must preserve user config or be template/theme report-only`);
153
+ }
154
+ }
155
+ }
156
+
157
+ function isSafePatchMode(patch) {
158
+ if (patch.mode === "merge-preserve") return true;
159
+ if (patch.mode === "template-only" && String(patch.file || "").endsWith(".example.json")) return true;
160
+ if (patch.mode === "report-only" && isThemeSelectionReportOnlyPatch(patch)) return true;
161
+ return false;
162
+ }
163
+
164
+ function isThemeSelectionReportOnlyPatch(patch) {
165
+ if (patch.file === "settings.json" && patch.path === "theme") return true;
166
+ if (patch.file === "settings.json.theme") return true;
167
+ return false;
168
+ }
169
+
170
+ function validateForbiddenStrategyText(entry, label, forbiddenFragments, problems) {
171
+ const policyText = [
172
+ entry.installStrategy,
173
+ entry.updateStrategy,
174
+ entry.repairStrategy,
175
+ ...(Array.isArray(entry.explicitCommands) ? entry.explicitCommands : []),
176
+ ].join(" ");
177
+ for (const fragment of forbiddenFragments) {
178
+ if (policyText.includes(fragment)) {
179
+ problems.push(`${label} uses forbidden behavior: ${fragment}`);
180
+ }
181
+ }
182
+ }
183
+
184
+ function validateManifestParity(entries, manifest, problems, warnings) {
185
+ const byId = new Map(entries.filter((entry) => entry?.id).map((entry) => [entry.id, entry]));
186
+
187
+ const managedLocalExtensions = Array.isArray(manifest.localExtensions)
188
+ ? manifest.localExtensions.filter((item) => item.owner === "pi67-managed")
189
+ : [];
190
+ for (const item of managedLocalExtensions) {
191
+ if (!byId.has(item.name)) {
192
+ problems.push(`managed local extension missing registry entry: ${item.name}`);
193
+ }
194
+ }
195
+
196
+ const theme = byId.get("pi-curated-themes");
197
+ if (theme) {
198
+ if (manifest.theme?.policy && theme.updateStrategy !== manifest.theme.policy) {
199
+ problems.push("theme extension registry policy must match manifest theme policy");
200
+ }
201
+ if (theme.configPatches?.some((patch) => patch.mode !== "report-only" || !isThemeSelectionReportOnlyPatch(patch))) {
202
+ problems.push("theme extension registry must only report current theme selection during update");
203
+ }
204
+ }
205
+
206
+ const sharedSkills = byId.get("shared-skills");
207
+ if (sharedSkills && manifest.sharedSkills?.policy && sharedSkills.updateStrategy !== manifest.sharedSkills.policy) {
208
+ problems.push("shared-skills extension registry policy must match manifest shared skills policy");
209
+ }
210
+
211
+ for (const entry of entries.filter((item) => item?.kind === "external-repo")) {
212
+ if (entry.updateStrategy !== "preserve-and-block-update-when-dirty") {
213
+ problems.push(`external repo ${entry.id} must block dirty updates`);
214
+ }
215
+ if (!entry.explicitCommands?.some((command) => command.includes("external update"))) {
216
+ warnings.push(`external repo ${entry.id} has no explicit external update command documented`);
217
+ }
218
+ }
219
+
220
+ const xtalpi = byId.get("xtalpi-pi-tools");
221
+ if (xtalpi) {
222
+ if (xtalpi.kind !== "local-provider") problems.push("xtalpi-pi-tools must remain a local-provider registry entry");
223
+ if (xtalpi.path !== "extensions/xtalpi-pi-tools") problems.push("xtalpi-pi-tools path must remain extensions/xtalpi-pi-tools");
224
+ if (xtalpi.repairStrategy !== "local-json-action-protocol-repair") {
225
+ problems.push("xtalpi-pi-tools must use local JSON action protocol repair");
226
+ }
227
+ }
228
+ }
229
+
230
+ function entryLabel(entry, index) {
231
+ return `extension ${entry?.id || `#${index}`}`;
232
+ }
233
+
234
+ function arrayOfStrings(value) {
235
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : [];
236
+ }
237
+
238
+ function isPlainObject(value) {
239
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
240
+ }
241
+
242
+ function emptySummary() {
243
+ return {
244
+ entries: 0,
245
+ requiredEntries: 0,
246
+ duplicateIds: 0,
247
+ smokeGates: 0,
248
+ configPatches: 0,
249
+ };
250
+ }
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { gitStatus, gitText } from "./git.mjs";
4
+ import { runCommand } from "./shell-runner.mjs";
5
+ import { CliError } from "./output.mjs";
6
+
7
+ export const EXTERNAL_REPOS = {
8
+ browser67: {
9
+ name: "browser67",
10
+ repoUrl: "https://github.com/bigKING67/browser67.git",
11
+ description: "Real browser / tmwd_browser / js-reverse runtime repo",
12
+ },
13
+ "design-craft": {
14
+ name: "design-craft",
15
+ repoUrl: "https://github.com/bigKING67/design-craft",
16
+ description: "Shared frontend design craft skills repo",
17
+ },
18
+ };
19
+
20
+ export function externalPath(ctx, name) {
21
+ return path.join(ctx.packagesDir, name);
22
+ }
23
+
24
+ export function listExternal(ctx) {
25
+ return Object.values(EXTERNAL_REPOS).map((repo) => externalStatus(ctx, repo.name));
26
+ }
27
+
28
+ export function externalStatus(ctx, name) {
29
+ const spec = EXTERNAL_REPOS[name];
30
+ if (!spec) throw new CliError(`unknown external repo: ${name}`, 2);
31
+ const dir = externalPath(ctx, name);
32
+ const exists = fs.existsSync(dir);
33
+ const git = exists ? gitStatus(dir) : null;
34
+ return {
35
+ ...spec,
36
+ path: dir,
37
+ exists,
38
+ git,
39
+ };
40
+ }
41
+
42
+ export function installExternal(ctx, name, { dryRun = false } = {}) {
43
+ const spec = EXTERNAL_REPOS[name];
44
+ if (!spec) throw new CliError(`unknown external repo: ${name}`, 2);
45
+ const dir = externalPath(ctx, name);
46
+ if (fs.existsSync(dir)) {
47
+ return { action: "skip", reason: "already exists", status: externalStatus(ctx, name) };
48
+ }
49
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
50
+ runCommand("git", ["clone", spec.repoUrl, dir], { dryRun });
51
+ return { action: dryRun ? "clone-dry-run" : "clone", status: externalStatus(ctx, name) };
52
+ }
53
+
54
+ export function updateExternal(ctx, name, { dryRun = false } = {}) {
55
+ const status = externalStatus(ctx, name);
56
+ if (!status.exists) {
57
+ return installExternal(ctx, name, { dryRun });
58
+ }
59
+ if (!status.git?.isRepo) {
60
+ throw new CliError(`external path is not a git repo: ${status.path}`);
61
+ }
62
+ if (status.git.dirty) {
63
+ throw new CliError(`external repo is dirty; not updating: ${status.path}`);
64
+ }
65
+ const branch = gitText(status.path, ["branch", "--show-current"]);
66
+ if (!branch) {
67
+ throw new CliError(`external repo is detached; not updating: ${status.path}`);
68
+ }
69
+ runCommand("git", ["-C", status.path, "pull", "--ff-only"], { dryRun });
70
+ return { action: dryRun ? "pull-dry-run" : "pull", status: externalStatus(ctx, name) };
71
+ }
@@ -0,0 +1,55 @@
1
+ import path from "node:path";
2
+ import { captureCommand } from "./shell-runner.mjs";
3
+
4
+ export function isGitRepo(repoRoot) {
5
+ return captureCommand("git", ["-C", repoRoot, "rev-parse", "--is-inside-work-tree"]).ok;
6
+ }
7
+
8
+ export function gitText(repoRoot, args, fallback = "") {
9
+ const result = captureCommand("git", ["-C", repoRoot, ...args]);
10
+ return result.ok ? result.stdout.trim() : fallback;
11
+ }
12
+
13
+ export function gitStatus(repoRoot) {
14
+ if (!isGitRepo(repoRoot)) {
15
+ return { ok: false, isRepo: false, dirty: false, short: "", branch: "", commit: "" };
16
+ }
17
+ const short = gitText(repoRoot, ["status", "--short"]);
18
+ const branchLine = gitText(repoRoot, ["status", "--short", "--branch"]).split(/\r?\n/)[0] || "";
19
+ const branch = gitText(repoRoot, ["branch", "--show-current"], "detached");
20
+ const commit = gitText(repoRoot, ["rev-parse", "--short=12", "HEAD"]);
21
+ const remote = gitText(repoRoot, ["remote", "get-url", "origin"]);
22
+ return {
23
+ ok: true,
24
+ isRepo: true,
25
+ dirty: short.length > 0,
26
+ short,
27
+ branchLine,
28
+ branch,
29
+ commit,
30
+ remote,
31
+ };
32
+ }
33
+
34
+ export function remoteHead(repoRoot, remote = "origin", branch = "") {
35
+ const currentBranch = branch || gitText(repoRoot, ["branch", "--show-current"]);
36
+ if (!currentBranch) {
37
+ return { ok: false, branch: "", commit: "", message: "no current branch" };
38
+ }
39
+ const remoteUrl = gitText(repoRoot, ["remote", "get-url", remote]);
40
+ if (!remoteUrl) {
41
+ return { ok: false, branch: currentBranch, commit: "", message: `missing remote: ${remote}` };
42
+ }
43
+ const result = captureCommand("git", ["ls-remote", "--heads", remoteUrl, currentBranch], {
44
+ timeoutMs: 8000,
45
+ });
46
+ if (!result.ok) {
47
+ return { ok: false, branch: currentBranch, commit: "", message: (result.stderr || result.error || "").trim() };
48
+ }
49
+ const commit = result.stdout.trim().split(/\s+/)[0] || "";
50
+ return { ok: Boolean(commit), branch: currentBranch, commit, message: commit ? "" : "remote branch not found" };
51
+ }
52
+
53
+ export function relativeRepoPath(repoRoot, file) {
54
+ return path.relative(repoRoot, file).replace(/\\/g, "/");
55
+ }