@tutar/graph-engineering 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.
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/bin/graph-engineering.mjs +60 -0
- package/lib/check.mjs +102 -0
- package/lib/constants.mjs +17 -0
- package/lib/files.mjs +83 -0
- package/lib/init.mjs +29 -0
- package/lib/manifest.mjs +31 -0
- package/lib/migrate.mjs +101 -0
- package/lib/package-assets.mjs +39 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/development-worktree.sh +32 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/github-development-ticket.mjs +246 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/stop-hook-drain.mjs +20 -0
- package/migrations/loop-engineering-927bd961/development/.github/loop-engineering/thread-record.mjs +36 -0
- package/migrations/loop-engineering-927bd961/development/.github/workflows/github-development-ticket.yml +124 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/capture-review.mjs +16 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/check-publication.mjs +23 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatibility-profile.json +67 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/codex-compatible-executor.mjs +130 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/github-api.mjs +20 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-case.mjs +125 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-config.json +19 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/pr-review-contract.mjs +2 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/prepare-review.mjs +50 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/publish-review.mjs +103 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/review-result.schema.json +93 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/loop-engineering/route-review.mjs +117 -0
- package/migrations/loop-engineering-927bd961/pr-review/.github/workflows/github-pr-review.yml +138 -0
- package/package.json +38 -0
- package/release-metadata.json +4 -0
- package/templates/development/.github/graph-engineering/development-worktree.sh +47 -0
- package/templates/development/.github/graph-engineering/github-development-ticket.mjs +246 -0
- package/templates/development/.github/graph-engineering/stop-hook-drain.mjs +20 -0
- package/templates/development/.github/graph-engineering/thread-record.mjs +39 -0
- package/templates/development/.github/workflows/github-development-ticket.yml +124 -0
- package/templates/pr-review/.github/graph-engineering/capture-review.mjs +16 -0
- package/templates/pr-review/.github/graph-engineering/check-publication.mjs +23 -0
- package/templates/pr-review/.github/graph-engineering/codex-compatibility-profile.json +67 -0
- package/templates/pr-review/.github/graph-engineering/codex-compatible-executor.mjs +130 -0
- package/templates/pr-review/.github/graph-engineering/github-api.mjs +20 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-case.mjs +125 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-config.json +19 -0
- package/templates/pr-review/.github/graph-engineering/pr-review-contract.mjs +2 -0
- package/templates/pr-review/.github/graph-engineering/prepare-review.mjs +50 -0
- package/templates/pr-review/.github/graph-engineering/publish-review.mjs +103 -0
- package/templates/pr-review/.github/graph-engineering/review-result.schema.json +93 -0
- package/templates/pr-review/.github/graph-engineering/route-review.mjs +117 -0
- package/templates/pr-review/.github/workflows/github-pr-review.yml +138 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tutar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @tutar/graph-engineering
|
|
2
|
+
|
|
3
|
+
为 GitHub 软件研发项目安装由项目自行拥有的 Graph Engineering Workflow Task。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx --yes @tutar/graph-engineering@0.3.0 init pr-review
|
|
7
|
+
npx --yes @tutar/graph-engineering@0.3.0 check pr-review
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
完整契约与支持边界见 [Graph Engineering](https://github.com/tutar/graph-engineering)。
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { checkTask, printCheck } from "../lib/check.mjs";
|
|
5
|
+
import { initTask } from "../lib/init.mjs";
|
|
6
|
+
import { migrate } from "../lib/migrate.mjs";
|
|
7
|
+
|
|
8
|
+
function usage() {
|
|
9
|
+
return `Usage:
|
|
10
|
+
graph-engineering init <pr-review|development> [--dry-run] [--project <path>]
|
|
11
|
+
graph-engineering check <pr-review|development> [--json] [--project <path>]
|
|
12
|
+
graph-engineering migrate [--apply] [--project <path>]`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parse(argv) {
|
|
16
|
+
const [command, maybeTask, ...rest] = argv;
|
|
17
|
+
const options = { command, task: command === "migrate" ? null : maybeTask, projectRoot: process.cwd(), dryRun: false, json: false, apply: false };
|
|
18
|
+
const args = command === "migrate" ? [maybeTask, ...rest].filter(Boolean) : rest;
|
|
19
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
20
|
+
const arg = args[index];
|
|
21
|
+
if (arg === "--project") {
|
|
22
|
+
if (!args[index + 1]) throw new Error("--project requires a path");
|
|
23
|
+
options.projectRoot = resolve(args[++index]);
|
|
24
|
+
} else if (arg === "--dry-run") options.dryRun = true;
|
|
25
|
+
else if (arg === "--json") options.json = true;
|
|
26
|
+
else if (arg === "--apply") options.apply = true;
|
|
27
|
+
else throw new Error(`unknown option: ${arg}`);
|
|
28
|
+
}
|
|
29
|
+
return options;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const options = parse(process.argv.slice(2));
|
|
34
|
+
if (options.command === "init") {
|
|
35
|
+
const plan = await initTask(options.task, options);
|
|
36
|
+
if (plan.dryRun) {
|
|
37
|
+
process.stdout.write(`DRY RUN: install ${plan.task}\n${plan.files.map((file) => `+ ${file}`).join("\n")}\n`);
|
|
38
|
+
} else {
|
|
39
|
+
process.stdout.write(`Installed ${plan.task} (${plan.files.length} files).\n`);
|
|
40
|
+
const report = await checkTask(options.task, options);
|
|
41
|
+
printCheck(report, options);
|
|
42
|
+
if (report.blocking) process.exitCode = 1;
|
|
43
|
+
}
|
|
44
|
+
} else if (options.command === "check") {
|
|
45
|
+
const report = await checkTask(options.task, options);
|
|
46
|
+
printCheck(report, options);
|
|
47
|
+
if (report.blocking) process.exitCode = 1;
|
|
48
|
+
} else if (options.command === "migrate") {
|
|
49
|
+
const result = await migrate(options);
|
|
50
|
+
for (const line of result.summary) process.stdout.write(`${line}\n`);
|
|
51
|
+
if (result.requiresApply) process.stdout.write("ACTION REQUIRED: rerun with --apply in a non-interactive environment.\n");
|
|
52
|
+
else process.stdout.write(result.applied ? "Migration applied.\n" : "Migration cancelled.\n");
|
|
53
|
+
} else {
|
|
54
|
+
throw new Error(usage());
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
process.stderr.write(`${error.message}\n`);
|
|
58
|
+
if (!String(error.message).startsWith("Usage:")) process.stderr.write(`${usage()}\n`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
package/lib/check.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { TASKS } from "./constants.mjs";
|
|
4
|
+
import { exists, listFiles, readJson, sha256, within } from "./files.mjs";
|
|
5
|
+
import { readManifest } from "./manifest.mjs";
|
|
6
|
+
import { taskTemplateRoot } from "./package-assets.mjs";
|
|
7
|
+
|
|
8
|
+
function result(status, check, detail, blocking = false) { return { status, check, detail, blocking }; }
|
|
9
|
+
function command(name, args = [], cwd) { return spawnSync(name, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
|
|
10
|
+
|
|
11
|
+
export async function checkTask(task, { projectRoot = process.cwd() } = {}) {
|
|
12
|
+
if (!TASKS[task]) throw new Error(`unknown task: ${task}`);
|
|
13
|
+
projectRoot = resolve(projectRoot);
|
|
14
|
+
const results = [];
|
|
15
|
+
const git = command("git", ["rev-parse", "--show-toplevel"], projectRoot);
|
|
16
|
+
if (git.status !== 0) results.push(result("ACTION REQUIRED", "git-repository", "Run the command from a Git repository root.", true));
|
|
17
|
+
else if (resolve(git.stdout.trim()) !== projectRoot) results.push(result("ACTION REQUIRED", "git-repository", "Run the command from the Git repository root.", true));
|
|
18
|
+
else results.push(result("PASS", "git-repository", projectRoot));
|
|
19
|
+
|
|
20
|
+
const templateRoot = await taskTemplateRoot(task);
|
|
21
|
+
const files = await listFiles(templateRoot);
|
|
22
|
+
const present = [];
|
|
23
|
+
const changed = [];
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const target = within(projectRoot, file);
|
|
26
|
+
if (await exists(target)) {
|
|
27
|
+
present.push(file);
|
|
28
|
+
if (await sha256(target) !== await sha256(join(templateRoot, file))) changed.push(file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (present.length === 0) results.push(result("ACTION REQUIRED", "workflow-files", `${task} is not installed.`));
|
|
32
|
+
else if (present.length !== files.length) results.push(result("ACTION REQUIRED", "workflow-files", `Partial installation: ${present.length}/${files.length} files exist.`, true));
|
|
33
|
+
else if (changed.length > 0) results.push(result("UNVERIFIED", "workflow-files", `${changed.length} project-owned file(s) differ from the packaged template.`));
|
|
34
|
+
else results.push(result("PASS", "workflow-files", `${files.length} files match the packaged template.`));
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const manifest = await readManifest(projectRoot);
|
|
38
|
+
if (!manifest?.installations?.[task]) results.push(result("ACTION REQUIRED", "installation-manifest", "Installation source is not recorded."));
|
|
39
|
+
else results.push(result("PASS", "installation-manifest", `${manifest.installations[task].cliVersion} ${manifest.installations[task].sourceCommit}`));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
results.push(result("ACTION REQUIRED", "installation-manifest", error.message, true));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const executable of ["gh", "codex"]) {
|
|
45
|
+
const probe = command(executable, ["--version"], projectRoot);
|
|
46
|
+
results.push(probe.status === 0
|
|
47
|
+
? result("PASS", `${executable}-executable`, probe.stdout.split("\n")[0].trim())
|
|
48
|
+
: result("UNVERIFIED", `${executable}-executable`, `${executable} is not available locally; confirm it on the selected runner.`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const gh = command("gh", ["repo", "view", "--json", "nameWithOwner"], projectRoot);
|
|
52
|
+
if (gh.status !== 0) {
|
|
53
|
+
results.push(result("UNVERIFIED", "github-repository", "GitHub repository access is unavailable from this environment."));
|
|
54
|
+
} else {
|
|
55
|
+
const nameWithOwner = JSON.parse(gh.stdout).nameWithOwner;
|
|
56
|
+
results.push(result("PASS", "github-repository", nameWithOwner));
|
|
57
|
+
if (TASKS[task].requiredLabels.length > 0) {
|
|
58
|
+
const labels = command("gh", ["label", "list", "--limit", "200", "--json", "name"], projectRoot);
|
|
59
|
+
if (labels.status !== 0) results.push(result("UNVERIFIED", "github-labels", "Unable to read repository labels."));
|
|
60
|
+
else {
|
|
61
|
+
const names = new Set(JSON.parse(labels.stdout).map(({ name }) => name));
|
|
62
|
+
const missing = TASKS[task].requiredLabels.filter((name) => !names.has(name));
|
|
63
|
+
results.push(missing.length === 0
|
|
64
|
+
? result("PASS", "github-labels", "Required labels exist.")
|
|
65
|
+
: result("ACTION REQUIRED", "github-labels", `Create: ${missing.join(", ")}`));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const runners = command("gh", ["api", `repos/${nameWithOwner}/actions/runners`, "--paginate", "--slurp"], projectRoot);
|
|
69
|
+
if (runners.status !== 0) {
|
|
70
|
+
results.push(result("UNVERIFIED", "runner-labels", "Unable to inspect repository runners; confirm [self-hosted, Linux, X64, codex] in GitHub Settings."));
|
|
71
|
+
} else {
|
|
72
|
+
const expected = ["self-hosted", "Linux", "X64", "codex"];
|
|
73
|
+
const compatible = JSON.parse(runners.stdout).flatMap(({ runners }) => runners ?? []).some(({ labels }) => {
|
|
74
|
+
const names = new Set(labels.map(({ name }) => name));
|
|
75
|
+
return expected.every((label) => names.has(label));
|
|
76
|
+
});
|
|
77
|
+
results.push(compatible
|
|
78
|
+
? result("PASS", "runner-labels", "A runner exposes [self-hosted, Linux, X64, codex].")
|
|
79
|
+
: result("ACTION REQUIRED", "runner-labels", "Register a dedicated runner with [self-hosted, Linux, X64, codex]."));
|
|
80
|
+
}
|
|
81
|
+
results.push(result("UNVERIFIED", "codex-runner-login", "Confirm that the Actions service user is logged in to Codex on the selected runner."));
|
|
82
|
+
if (task === "development") {
|
|
83
|
+
const permissions = command("gh", ["api", `repos/${nameWithOwner}/actions/permissions/workflow`], projectRoot);
|
|
84
|
+
if (permissions.status !== 0) results.push(result("UNVERIFIED", "actions-write-policy", "Unable to inspect the repository Actions workflow permission policy."));
|
|
85
|
+
else {
|
|
86
|
+
const policy = JSON.parse(permissions.stdout);
|
|
87
|
+
results.push(policy.default_workflow_permissions === "write"
|
|
88
|
+
? result("PASS", "actions-write-policy", "Default workflow permissions allow write access; review the Workflow's explicit permissions before commit.")
|
|
89
|
+
: result("ACTION REQUIRED", "actions-write-policy", "Allow the Development workflow's required write permissions in GitHub Settings."));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { task, ready: results.every(({ status }) => status === "PASS"), blocking: results.some(({ blocking }) => blocking), results };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function printCheck(report, { json = false, stream = process.stdout } = {}) {
|
|
97
|
+
if (json) stream.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
98
|
+
else {
|
|
99
|
+
for (const item of report.results) stream.write(`${item.status.padEnd(15)} ${item.check}: ${item.detail}\n`);
|
|
100
|
+
stream.write(`${report.ready ? "READY" : "ACTION REQUIRED"}: ${report.task}\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const PRODUCT_VERSION = "0.3.0";
|
|
2
|
+
export const TASKS = Object.freeze({
|
|
3
|
+
development: {
|
|
4
|
+
workflow: "github-development-ticket.yml",
|
|
5
|
+
profile: "github-development-ticket/current",
|
|
6
|
+
requiredLabels: ["ready-for-agent", "development-ticket", "in-progress"],
|
|
7
|
+
},
|
|
8
|
+
"pr-review": {
|
|
9
|
+
workflow: "github-pr-review.yml",
|
|
10
|
+
profile: "github-pr-review/codex/current",
|
|
11
|
+
requiredLabels: [],
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const MANIFEST_PATH = ".github/graph-engineering/installation.json";
|
|
16
|
+
export const LEGACY_NAMESPACE = "loop-engineering";
|
|
17
|
+
export const CURRENT_NAMESPACE = "graph-engineering";
|
package/lib/files.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { access, constants, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export const packageRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
8
|
+
|
|
9
|
+
export async function exists(path) {
|
|
10
|
+
try { await access(path); return true; } catch { return false; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function listFiles(root) {
|
|
14
|
+
const output = [];
|
|
15
|
+
async function visit(directory) {
|
|
16
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
17
|
+
const path = join(directory, entry.name);
|
|
18
|
+
if (entry.isSymbolicLink()) throw new Error(`symbolic links are not allowed in package assets: ${path}`);
|
|
19
|
+
if (entry.isDirectory()) await visit(path);
|
|
20
|
+
else if (entry.isFile()) output.push(relative(root, path));
|
|
21
|
+
else throw new Error(`unsupported package asset: ${path}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
await visit(root);
|
|
25
|
+
return output.sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function within(root, relativePath) {
|
|
29
|
+
const target = resolve(root, relativePath);
|
|
30
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) throw new Error(`path escapes project root: ${relativePath}`);
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function sha256(path) {
|
|
35
|
+
const content = await readFile(path);
|
|
36
|
+
return createHash("sha256").update(content).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function copyFiles({ sourceRoot, projectRoot, files }) {
|
|
40
|
+
const createdFiles = [];
|
|
41
|
+
const createdDirectories = new Set();
|
|
42
|
+
try {
|
|
43
|
+
for (const relativePath of files) {
|
|
44
|
+
const target = within(projectRoot, relativePath);
|
|
45
|
+
let directory = dirname(target);
|
|
46
|
+
const missing = [];
|
|
47
|
+
while (directory.startsWith(projectRoot) && !(await exists(directory))) {
|
|
48
|
+
missing.push(directory);
|
|
49
|
+
directory = dirname(directory);
|
|
50
|
+
}
|
|
51
|
+
await mkdir(dirname(target), { recursive: true });
|
|
52
|
+
for (const item of missing) createdDirectories.add(item);
|
|
53
|
+
await copyFile(join(sourceRoot, relativePath), target, constants.COPYFILE_EXCL);
|
|
54
|
+
createdFiles.push(target);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
for (const path of createdFiles.reverse()) await rm(path, { force: true });
|
|
58
|
+
for (const path of [...createdDirectories].sort((a, b) => b.length - a.length)) {
|
|
59
|
+
try { await rm(path); } catch {}
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readJson(path) {
|
|
66
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function writeJson(path, value) {
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function isDirectory(path) {
|
|
75
|
+
try { return (await stat(path)).isDirectory(); } catch { return false; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assertGitRoot(projectRoot) {
|
|
79
|
+
const result = spawnSync("git", ["-C", projectRoot, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
80
|
+
if (result.status !== 0 || resolve(result.stdout.trim()) !== resolve(projectRoot)) {
|
|
81
|
+
throw new Error("run the command from a Git repository root or pass --project <root>");
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { TASKS } from "./constants.mjs";
|
|
3
|
+
import { assertGitRoot, copyFiles, exists, listFiles, within } from "./files.mjs";
|
|
4
|
+
import { nextManifest, readManifest, replaceManifest } from "./manifest.mjs";
|
|
5
|
+
import { installationRecord, releaseMetadata, taskTemplateRoot } from "./package-assets.mjs";
|
|
6
|
+
|
|
7
|
+
export async function initTask(task, { projectRoot = process.cwd(), dryRun = false } = {}) {
|
|
8
|
+
if (!TASKS[task]) throw new Error(`unknown task: ${task}`);
|
|
9
|
+
projectRoot = resolve(projectRoot);
|
|
10
|
+
assertGitRoot(projectRoot);
|
|
11
|
+
const sourceRoot = await taskTemplateRoot(task);
|
|
12
|
+
const files = await listFiles(sourceRoot);
|
|
13
|
+
const conflicts = [];
|
|
14
|
+
for (const file of files) if (await exists(within(projectRoot, file))) conflicts.push(file);
|
|
15
|
+
const existingManifest = await readManifest(projectRoot);
|
|
16
|
+
if (existingManifest?.installations?.[task]) conflicts.push(".github/graph-engineering/installation.json (task already recorded)");
|
|
17
|
+
if (conflicts.length > 0) throw new Error(`installation conflicts:\n${conflicts.map((item) => `- ${item}`).join("\n")}`);
|
|
18
|
+
const metadata = await releaseMetadata();
|
|
19
|
+
const manifest = nextManifest(existingManifest, installationRecord(task, metadata));
|
|
20
|
+
if (!dryRun) {
|
|
21
|
+
await copyFiles({ sourceRoot, projectRoot, files });
|
|
22
|
+
try { await replaceManifest(projectRoot, manifest); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
for (const file of files.reverse()) await import("node:fs/promises").then(({ rm }) => rm(within(projectRoot, file), { force: true }));
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { task, dryRun, files, manifest };
|
|
29
|
+
}
|
package/lib/manifest.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { MANIFEST_PATH, PRODUCT_VERSION } from "./constants.mjs";
|
|
4
|
+
import { exists, within } from "./files.mjs";
|
|
5
|
+
|
|
6
|
+
export async function readManifest(projectRoot) {
|
|
7
|
+
const path = within(projectRoot, MANIFEST_PATH);
|
|
8
|
+
if (!(await exists(path))) return null;
|
|
9
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
10
|
+
if (value.schemaVersion !== 1 || typeof value.installations !== "object" || Array.isArray(value.installations)) {
|
|
11
|
+
throw new Error(`${MANIFEST_PATH} is not a recognized Graph Engineering installation manifest`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function nextManifest(existing, record) {
|
|
17
|
+
return {
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
product: "@tutar/graph-engineering",
|
|
20
|
+
productVersion: PRODUCT_VERSION,
|
|
21
|
+
installations: { ...(existing?.installations ?? {}), [record.task]: record },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function replaceManifest(projectRoot, value) {
|
|
26
|
+
const path = within(projectRoot, MANIFEST_PATH);
|
|
27
|
+
await mkdir(dirname(path), { recursive: true });
|
|
28
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
29
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
|
|
30
|
+
try { await rename(temporary, path); } catch (error) { await rm(temporary, { force: true }); throw error; }
|
|
31
|
+
}
|
package/lib/migrate.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { MANIFEST_PATH, TASKS } from "./constants.mjs";
|
|
7
|
+
import { assertGitRoot, copyFiles, exists, listFiles, sha256, within } from "./files.mjs";
|
|
8
|
+
import { nextManifest, readManifest, replaceManifest } from "./manifest.mjs";
|
|
9
|
+
import { installationRecord, legacyTemplateRoot, releaseMetadata, taskTemplateRoot } from "./package-assets.mjs";
|
|
10
|
+
|
|
11
|
+
async function detect(projectRoot) {
|
|
12
|
+
const found = [];
|
|
13
|
+
for (const [task, definition] of Object.entries(TASKS)) {
|
|
14
|
+
if (await exists(within(projectRoot, `.github/workflows/${definition.workflow}`))) found.push(task);
|
|
15
|
+
}
|
|
16
|
+
return found;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function validateBaseline(projectRoot, task, baselineRoot) {
|
|
20
|
+
const files = await listFiles(baselineRoot);
|
|
21
|
+
const mismatches = [];
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
const target = within(projectRoot, file);
|
|
24
|
+
if (!(await exists(target)) || await sha256(target) !== await sha256(join(baselineRoot, file))) mismatches.push(file);
|
|
25
|
+
}
|
|
26
|
+
return { files, mismatches };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function migrate({ projectRoot = process.cwd(), apply = false, interactive = Boolean(process.stdin.isTTY) } = {}) {
|
|
30
|
+
projectRoot = resolve(projectRoot);
|
|
31
|
+
assertGitRoot(projectRoot);
|
|
32
|
+
const tasks = await detect(projectRoot);
|
|
33
|
+
if (tasks.length === 0) throw new Error("no supported Loop Engineering installation was detected");
|
|
34
|
+
if (await exists(within(projectRoot, MANIFEST_PATH))) throw new Error("a Graph Engineering installation manifest already exists");
|
|
35
|
+
const plans = [];
|
|
36
|
+
for (const task of tasks) {
|
|
37
|
+
const baselineRoot = await legacyTemplateRoot(task);
|
|
38
|
+
const validated = await validateBaseline(projectRoot, task, baselineRoot);
|
|
39
|
+
if (validated.mismatches.length > 0) {
|
|
40
|
+
throw new Error(`cannot safely migrate modified or unknown ${task} files:\n${validated.mismatches.map((item) => `- ${item}`).join("\n")}`);
|
|
41
|
+
}
|
|
42
|
+
const currentRoot = await taskTemplateRoot(task);
|
|
43
|
+
plans.push({ task, oldRoot: baselineRoot, oldFiles: validated.files, currentRoot, newFiles: await listFiles(currentRoot) });
|
|
44
|
+
}
|
|
45
|
+
const replacedPaths = new Set(plans.flatMap(({ oldFiles }) => oldFiles));
|
|
46
|
+
const conflicts = [];
|
|
47
|
+
for (const { newFiles } of plans) {
|
|
48
|
+
for (const file of newFiles) {
|
|
49
|
+
if (!replacedPaths.has(file) && await exists(within(projectRoot, file))) conflicts.push(file);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (conflicts.length > 0) throw new Error(`migration conflicts:\n${[...new Set(conflicts)].map((item) => `- ${item}`).join("\n")}`);
|
|
53
|
+
const summary = plans.flatMap(({ task, oldFiles, newFiles }) => [
|
|
54
|
+
`${task}: remove ${oldFiles.length} recognized Loop Engineering files`,
|
|
55
|
+
...oldFiles.map((file) => `- ${file}`),
|
|
56
|
+
`${task}: install ${newFiles.length} Graph Engineering files`,
|
|
57
|
+
...newFiles.map((file) => `+ ${file}`),
|
|
58
|
+
]);
|
|
59
|
+
if (!apply) {
|
|
60
|
+
if (!interactive) return { applied: false, requiresApply: true, tasks, summary };
|
|
61
|
+
const prompt = createInterface({ input, output });
|
|
62
|
+
const answer = await prompt.question(`${summary.join("\n")}\nApply migration? [y/N] `);
|
|
63
|
+
prompt.close();
|
|
64
|
+
if (!/^y(es)?$/i.test(answer.trim())) return { applied: false, requiresApply: false, tasks, summary };
|
|
65
|
+
}
|
|
66
|
+
const metadata = await releaseMetadata();
|
|
67
|
+
let manifest = await readManifest(projectRoot);
|
|
68
|
+
const backup = await mkdtemp(join(tmpdir(), "graph-engineering-migrate-"));
|
|
69
|
+
for (const plan of plans) {
|
|
70
|
+
for (const file of plan.oldFiles) {
|
|
71
|
+
const target = within(backup, file);
|
|
72
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
73
|
+
await cp(within(projectRoot, file), target);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const installed = [];
|
|
77
|
+
try {
|
|
78
|
+
for (const plan of plans) {
|
|
79
|
+
for (const file of plan.oldFiles) await rm(within(projectRoot, file), { force: true });
|
|
80
|
+
await copyFiles({ sourceRoot: plan.currentRoot, projectRoot, files: plan.newFiles });
|
|
81
|
+
installed.push(...plan.newFiles);
|
|
82
|
+
manifest = nextManifest(manifest, installationRecord(plan.task, metadata));
|
|
83
|
+
}
|
|
84
|
+
await replaceManifest(projectRoot, manifest);
|
|
85
|
+
await rm(within(projectRoot, ".github/loop-engineering"), { recursive: true, force: true });
|
|
86
|
+
await mkdir(within(projectRoot, ".github/graph-engineering"), { recursive: true });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
for (const file of installed.reverse()) await rm(within(projectRoot, file), { force: true });
|
|
89
|
+
for (const plan of plans) {
|
|
90
|
+
for (const file of plan.oldFiles) {
|
|
91
|
+
const target = within(projectRoot, file);
|
|
92
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
93
|
+
await cp(within(backup, file), target);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
} finally {
|
|
98
|
+
await rm(backup, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
return { applied: true, requiresApply: false, tasks, summary };
|
|
101
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { CURRENT_NAMESPACE, PRODUCT_VERSION, TASKS } from "./constants.mjs";
|
|
4
|
+
import { exists, packageRoot, readJson } from "./files.mjs";
|
|
5
|
+
|
|
6
|
+
export async function taskTemplateRoot(task) {
|
|
7
|
+
const bundled = join(packageRoot, "templates", task);
|
|
8
|
+
if (await exists(bundled)) return bundled;
|
|
9
|
+
const source = resolve(packageRoot, "../..", "workflow-tasks", task, "files");
|
|
10
|
+
if (await exists(source)) return source;
|
|
11
|
+
throw new Error(`missing packaged template for ${task}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function legacyTemplateRoot(task) {
|
|
15
|
+
const root = join(packageRoot, "migrations", "loop-engineering-927bd961", task);
|
|
16
|
+
if (!(await exists(root))) throw new Error("migration assets are missing; run this command from a published package or npm pack output");
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function releaseMetadata() {
|
|
21
|
+
const path = join(packageRoot, "release-metadata.json");
|
|
22
|
+
if (await exists(path)) return readJson(path);
|
|
23
|
+
let sourceCommit = "working-tree";
|
|
24
|
+
try {
|
|
25
|
+
sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: resolve(packageRoot, "../.."), encoding: "utf8" }).trim();
|
|
26
|
+
} catch {}
|
|
27
|
+
return { productVersion: PRODUCT_VERSION, sourceCommit };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function installationRecord(task, metadata) {
|
|
31
|
+
return {
|
|
32
|
+
cliVersion: PRODUCT_VERSION,
|
|
33
|
+
sourceCommit: metadata.sourceCommit,
|
|
34
|
+
task,
|
|
35
|
+
definition: TASKS[task].profile.split("/codex/")[0],
|
|
36
|
+
profile: TASKS[task].profile,
|
|
37
|
+
namespace: CURRENT_NAMESPACE,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
interrupted_ref="refs/loop-engineering/interrupted/${TARGET_BRANCH}"
|
|
5
|
+
interrupted_stash_ref="refs/loop-engineering/interrupted-stash/${TARGET_BRANCH}"
|
|
6
|
+
|
|
7
|
+
case "${1:-}" in
|
|
8
|
+
prepare)
|
|
9
|
+
if git show-ref --verify --quiet "${interrupted_ref}"; then
|
|
10
|
+
git switch --force-create "${TARGET_BRANCH}" "${interrupted_ref}"
|
|
11
|
+
git rebase "${BASE_BRANCH}"
|
|
12
|
+
elif git ls-remote --exit-code origin "refs/heads/${TARGET_BRANCH}" >/dev/null 2>&1; then
|
|
13
|
+
git fetch origin "${TARGET_BRANCH}"
|
|
14
|
+
git switch --create "${TARGET_BRANCH}" --track "origin/${TARGET_BRANCH}"
|
|
15
|
+
else
|
|
16
|
+
git switch --create "${TARGET_BRANCH}"
|
|
17
|
+
fi
|
|
18
|
+
;;
|
|
19
|
+
restore)
|
|
20
|
+
if git show-ref --verify --quiet "${interrupted_stash_ref}"; then
|
|
21
|
+
git stash apply --index "${interrupted_stash_ref}"
|
|
22
|
+
git update-ref -d "${interrupted_stash_ref}"
|
|
23
|
+
fi
|
|
24
|
+
if git show-ref --verify --quiet "${interrupted_ref}"; then
|
|
25
|
+
git update-ref -d "${interrupted_ref}"
|
|
26
|
+
fi
|
|
27
|
+
;;
|
|
28
|
+
*)
|
|
29
|
+
echo "usage: development-worktree.sh prepare|restore" >&2
|
|
30
|
+
exit 2
|
|
31
|
+
;;
|
|
32
|
+
esac
|