nubos-pilot 0.1.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/np-ai-researcher.md +140 -0
- package/agents/np-code-fixer.md +363 -0
- package/agents/np-code-reviewer.md +351 -0
- package/agents/np-domain-researcher.md +136 -0
- package/agents/np-eval-auditor.md +167 -0
- package/agents/np-eval-planner.md +153 -0
- package/agents/np-executor.md +72 -0
- package/agents/np-framework-selector.md +171 -0
- package/agents/np-nyquist-auditor.md +185 -0
- package/agents/np-plan-checker.md +165 -0
- package/agents/np-planner.md +199 -0
- package/agents/np-researcher.md +150 -0
- package/agents/np-security-auditor.md +206 -0
- package/agents/np-ui-auditor.md +369 -0
- package/agents/np-ui-checker.md +192 -0
- package/agents/np-ui-researcher.md +324 -0
- package/agents/np-verifier.md +79 -0
- package/bin/check-coverage.cjs +40 -0
- package/bin/check-workflows.cjs +171 -0
- package/bin/check-workflows.test.cjs +208 -0
- package/bin/install.js +500 -0
- package/bin/np-tools/_commands.cjs +70 -0
- package/bin/np-tools/add-tests.cjs +171 -0
- package/bin/np-tools/add-tests.test.cjs +122 -0
- package/bin/np-tools/add-todo.cjs +108 -0
- package/bin/np-tools/add-todo.test.cjs +112 -0
- package/bin/np-tools/agent-skills.cjs +14 -0
- package/bin/np-tools/agent-skills.test.cjs +42 -0
- package/bin/np-tools/ai-integration-phase.cjs +109 -0
- package/bin/np-tools/ai-integration-phase.test.cjs +123 -0
- package/bin/np-tools/askuser.cjs +53 -0
- package/bin/np-tools/askuser.test.cjs +49 -0
- package/bin/np-tools/autonomous.cjs +69 -0
- package/bin/np-tools/autonomous.test.cjs +74 -0
- package/bin/np-tools/checkpoint.cjs +101 -0
- package/bin/np-tools/checkpoint.test.cjs +119 -0
- package/bin/np-tools/code-review.cjs +133 -0
- package/bin/np-tools/code-review.test.cjs +96 -0
- package/bin/np-tools/commit-task.cjs +120 -0
- package/bin/np-tools/commit-task.test.cjs +160 -0
- package/bin/np-tools/commit.cjs +103 -0
- package/bin/np-tools/commit.test.cjs +93 -0
- package/bin/np-tools/config.cjs +101 -0
- package/bin/np-tools/config.test.cjs +71 -0
- package/bin/np-tools/discuss-phase-power.cjs +265 -0
- package/bin/np-tools/discuss-phase-power.test.cjs +242 -0
- package/bin/np-tools/discuss-phase.cjs +132 -0
- package/bin/np-tools/discuss-phase.test.cjs +148 -0
- package/bin/np-tools/dispatch.cjs +116 -0
- package/bin/np-tools/doctor.cjs +242 -0
- package/bin/np-tools/eval-review.cjs +116 -0
- package/bin/np-tools/eval-review.test.cjs +123 -0
- package/bin/np-tools/execute-phase.cjs +182 -0
- package/bin/np-tools/execute-phase.test.cjs +116 -0
- package/bin/np-tools/execute-plan.cjs +124 -0
- package/bin/np-tools/execute-plan.test.cjs +82 -0
- package/bin/np-tools/help.cjs +28 -0
- package/bin/np-tools/help.test.cjs +29 -0
- package/bin/np-tools/init-dispatch.test.cjs +91 -0
- package/bin/np-tools/metrics.cjs +97 -0
- package/bin/np-tools/metrics.test.cjs +188 -0
- package/bin/np-tools/new-milestone.cjs +288 -0
- package/bin/np-tools/new-milestone.test.cjs +166 -0
- package/bin/np-tools/new-project.cjs +284 -0
- package/bin/np-tools/new-project.test.cjs +165 -0
- package/bin/np-tools/next.cjs +7 -0
- package/bin/np-tools/next.test.cjs +30 -0
- package/bin/np-tools/park.cjs +48 -0
- package/bin/np-tools/park.test.cjs +50 -0
- package/bin/np-tools/pause-work.cjs +24 -0
- package/bin/np-tools/pause-work.test.cjs +74 -0
- package/bin/np-tools/phase.cjs +71 -0
- package/bin/np-tools/phase.test.cjs +81 -0
- package/bin/np-tools/plan-diff.cjs +57 -0
- package/bin/np-tools/plan-diff.test.cjs +134 -0
- package/bin/np-tools/plan-milestone-gaps.cjs +115 -0
- package/bin/np-tools/plan-milestone-gaps.test.cjs +122 -0
- package/bin/np-tools/plan-phase.cjs +350 -0
- package/bin/np-tools/plan-phase.test.cjs +263 -0
- package/bin/np-tools/progress.cjs +7 -0
- package/bin/np-tools/progress.test.cjs +44 -0
- package/bin/np-tools/queue.cjs +213 -0
- package/bin/np-tools/research-phase.cjs +144 -0
- package/bin/np-tools/research-phase.test.cjs +154 -0
- package/bin/np-tools/reset-slice.cjs +17 -0
- package/bin/np-tools/reset-slice.test.cjs +96 -0
- package/bin/np-tools/resolve-model.cjs +110 -0
- package/bin/np-tools/resolve-model.test.cjs +200 -0
- package/bin/np-tools/resume-work.cjs +76 -0
- package/bin/np-tools/resume-work.test.cjs +91 -0
- package/bin/np-tools/skip.cjs +48 -0
- package/bin/np-tools/skip.test.cjs +66 -0
- package/bin/np-tools/slug.cjs +34 -0
- package/bin/np-tools/slug.test.cjs +46 -0
- package/bin/np-tools/state.cjs +16 -0
- package/bin/np-tools/state.test.cjs +40 -0
- package/bin/np-tools/stats.cjs +151 -0
- package/bin/np-tools/stats.test.cjs +118 -0
- package/bin/np-tools/triage.cjs +128 -0
- package/bin/np-tools/ui-phase.cjs +108 -0
- package/bin/np-tools/ui-phase.test.cjs +121 -0
- package/bin/np-tools/ui-review.cjs +108 -0
- package/bin/np-tools/ui-review.test.cjs +120 -0
- package/bin/np-tools/undo-task.cjs +31 -0
- package/bin/np-tools/undo-task.test.cjs +117 -0
- package/bin/np-tools/undo.cjs +43 -0
- package/bin/np-tools/undo.test.cjs +120 -0
- package/bin/np-tools/unpark.cjs +48 -0
- package/bin/np-tools/unpark.test.cjs +50 -0
- package/bin/np-tools/verify-work.cjs +186 -0
- package/bin/np-tools/verify-work.test.cjs +97 -0
- package/docs/adr/0001-no-daemon-invariant.md +82 -0
- package/docs/adr/0002-zero-runtime-dependencies.md +90 -0
- package/docs/adr/0003-max-six-unit-types.md +85 -0
- package/docs/adr/0004-atomic-commit-per-unit.md +102 -0
- package/docs/adr/0005-three-orthogonal-file-trees.md +98 -0
- package/docs/adr/0006-yaml-dependency-amendment.md +60 -0
- package/docs/adr/README.md +27 -0
- package/docs/agent-frontmatter-schema.md +84 -0
- package/docs/phase-artifact-schemas.md +292 -0
- package/docs/phase-directory-layout.md +82 -0
- package/lib/__tests__/README.md +1 -0
- package/lib/agents.cjs +98 -0
- package/lib/agents.test.cjs +286 -0
- package/lib/askuser.cjs +36 -0
- package/lib/askuser.test.cjs +310 -0
- package/lib/checkpoint.cjs +135 -0
- package/lib/checkpoint.test.cjs +184 -0
- package/lib/core.cjs +165 -0
- package/lib/core.test.cjs +405 -0
- package/lib/fixtures/README.md +1 -0
- package/lib/fixtures/phase-tree/README.md +1 -0
- package/lib/fixtures/plans/cycle/PLAN.md +16 -0
- package/lib/fixtures/plans/cycle/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/cycle/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/cycle/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/linear/PLAN.md +16 -0
- package/lib/fixtures/plans/linear/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/linear/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/linear/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/parallel/PLAN.md +16 -0
- package/lib/fixtures/plans/parallel/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/parallel/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/parallel/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/wave-conflict/PLAN.md +16 -0
- package/lib/fixtures/plans/wave-conflict/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/wave-conflict/tasks/T-02.md +20 -0
- package/lib/fixtures/roadmap/ROADMAP-malformed.md +3 -0
- package/lib/fixtures/roadmap/ROADMAP-minimal.md +51 -0
- package/lib/fixtures/roadmap/roadmap-malformed.yaml +7 -0
- package/lib/fixtures/roadmap/roadmap-minimal.yaml +40 -0
- package/lib/fixtures/roadmap/roadmap-ten-phases.yaml +101 -0
- package/lib/fixtures/templates/phase-context.md +6 -0
- package/lib/fixtures/templates/plan-skeleton.md +6 -0
- package/lib/frontmatter.cjs +251 -0
- package/lib/frontmatter.test.cjs +177 -0
- package/lib/gaps.cjs +197 -0
- package/lib/gaps.test.cjs +200 -0
- package/lib/git.cjs +207 -0
- package/lib/git.test.cjs +305 -0
- package/lib/install/agents-md.cjs +77 -0
- package/lib/install/backup.cjs +70 -0
- package/lib/install/codex-toml.cjs +440 -0
- package/lib/install/managed-block.cjs +30 -0
- package/lib/install/manifest.cjs +148 -0
- package/lib/install/mcp-writer.cjs +127 -0
- package/lib/install/runtime-detect.cjs +44 -0
- package/lib/install/staging.cjs +149 -0
- package/lib/metrics-aggregate.cjs +229 -0
- package/lib/metrics-aggregate.test.cjs +192 -0
- package/lib/metrics.cjs +120 -0
- package/lib/metrics.test.cjs +182 -0
- package/lib/model-aliases.regression.test.cjs +16 -0
- package/lib/model-profiles.cjs +42 -0
- package/lib/model-profiles.test.cjs +61 -0
- package/lib/next.cjs +236 -0
- package/lib/next.test.cjs +194 -0
- package/lib/phase.cjs +95 -0
- package/lib/phase.test.cjs +189 -0
- package/lib/plan-checker-contract.test.cjs +72 -0
- package/lib/plan-diff.cjs +173 -0
- package/lib/plan-diff.test.cjs +217 -0
- package/lib/plan.cjs +85 -0
- package/lib/plan.test.cjs +263 -0
- package/lib/progress.cjs +95 -0
- package/lib/progress.test.cjs +116 -0
- package/lib/researcher-contract.test.cjs +61 -0
- package/lib/roadmap-render.cjs +206 -0
- package/lib/roadmap-render.test.cjs +121 -0
- package/lib/roadmap.cjs +416 -0
- package/lib/roadmap.test.cjs +371 -0
- package/lib/runtime/_contract.test.cjs +61 -0
- package/lib/runtime/_readline.cjs +119 -0
- package/lib/runtime/_readline.test.cjs +126 -0
- package/lib/runtime/claude.cjs +48 -0
- package/lib/runtime/claude.test.cjs +101 -0
- package/lib/runtime/codex.cjs +35 -0
- package/lib/runtime/codex.test.cjs +114 -0
- package/lib/runtime/gemini.cjs +35 -0
- package/lib/runtime/gemini.test.cjs +109 -0
- package/lib/runtime/index.cjs +49 -0
- package/lib/runtime/index.test.cjs +181 -0
- package/lib/runtime/opencode.cjs +35 -0
- package/lib/runtime/opencode.test.cjs +124 -0
- package/lib/state.cjs +205 -0
- package/lib/state.test.cjs +264 -0
- package/lib/surface-audit.test.cjs +46 -0
- package/lib/tasks.cjs +327 -0
- package/lib/tasks.test.cjs +389 -0
- package/lib/template.cjs +66 -0
- package/lib/template.test.cjs +159 -0
- package/lib/undo.cjs +179 -0
- package/lib/undo.test.cjs +261 -0
- package/lib/verify.cjs +116 -0
- package/lib/verify.test.cjs +187 -0
- package/np-tools.cjs +303 -0
- package/package.json +39 -0
- package/templates/AI-SPEC.md +90 -0
- package/templates/CONTEXT.md +32 -0
- package/templates/PLAN.md +69 -0
- package/templates/PROJECT.md +60 -0
- package/templates/REQUIREMENTS.md +38 -0
- package/templates/SECURITY.md +61 -0
- package/templates/UI-SPEC.md +64 -0
- package/templates/VALIDATION.md +76 -0
- package/templates/claude/payload/README.md +11 -0
- package/templates/opencode/opencode.json +6 -0
- package/templates/opencode/payload/AGENTS.md +9 -0
- package/workflows/add-backlog.md +212 -0
- package/workflows/add-tests.md +69 -0
- package/workflows/add-todo.md +222 -0
- package/workflows/ai-integration-phase.md +230 -0
- package/workflows/autonomous.md +94 -0
- package/workflows/cleanup.md +325 -0
- package/workflows/code-review-fix.md +435 -0
- package/workflows/code-review.md +447 -0
- package/workflows/discuss-phase-assumptions.md +269 -0
- package/workflows/discuss-phase-power.md +139 -0
- package/workflows/discuss-phase.md +386 -0
- package/workflows/dispatch.md +9 -0
- package/workflows/doctor.md +10 -0
- package/workflows/eval-review.md +243 -0
- package/workflows/execute-phase.md +142 -0
- package/workflows/execute-plan.md +82 -0
- package/workflows/help.md +8 -0
- package/workflows/new-milestone.md +166 -0
- package/workflows/new-project.md +213 -0
- package/workflows/next.md +8 -0
- package/workflows/note.md +244 -0
- package/workflows/park.md +29 -0
- package/workflows/pause-work.md +34 -0
- package/workflows/plan-milestone-gaps.md +233 -0
- package/workflows/plan-phase.md +351 -0
- package/workflows/progress.md +8 -0
- package/workflows/queue.md +9 -0
- package/workflows/research-phase.md +327 -0
- package/workflows/reset-slice.md +39 -0
- package/workflows/resume-work.md +79 -0
- package/workflows/review.md +489 -0
- package/workflows/secure-phase.md +209 -0
- package/workflows/session-report.md +243 -0
- package/workflows/skip.md +29 -0
- package/workflows/state.md +7 -0
- package/workflows/stats.md +170 -0
- package/workflows/thread.md +214 -0
- package/workflows/triage.md +9 -0
- package/workflows/ui-phase.md +246 -0
- package/workflows/ui-review.md +222 -0
- package/workflows/undo-task.md +42 -0
- package/workflows/undo.md +55 -0
- package/workflows/unpark.md +29 -0
- package/workflows/validate-phase.md +231 -0
- package/workflows/verify-work.md +83 -0
package/lib/git.cjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const { execFileSync } = require('node:child_process');
|
|
2
|
+
const { NubosPilotError } = require('./core.cjs');
|
|
3
|
+
const { TASK_ID_RE } = require('./tasks.cjs');
|
|
4
|
+
|
|
5
|
+
function _isFatalCheckIgnore(err) {
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
return err && err.status !== 1;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isPathIgnored(p) {
|
|
12
|
+
try {
|
|
13
|
+
execFileSync('git', ['check-ignore', '--quiet', '--', p], { stdio: 'pipe' });
|
|
14
|
+
return true;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if (err && err.status === 1) return false;
|
|
17
|
+
if (err && err.status === 128) {
|
|
18
|
+
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertCommittablePaths(paths) {
|
|
26
|
+
if (!Array.isArray(paths)) {
|
|
27
|
+
throw new NubosPilotError(
|
|
28
|
+
'commit-paths-invalid',
|
|
29
|
+
'assertCommittablePaths expects an array of paths',
|
|
30
|
+
{ got: typeof paths },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const ignored = [];
|
|
34
|
+
for (const p of paths) {
|
|
35
|
+
try {
|
|
36
|
+
execFileSync('git', ['check-ignore', '--quiet', '--', p], { stdio: 'pipe' });
|
|
37
|
+
ignored.push(p);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (_isFatalCheckIgnore(err)) {
|
|
40
|
+
if (err.status === 128) throw err;
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (ignored.length > 0 && ignored.length === paths.length) {
|
|
46
|
+
|
|
47
|
+
throw new NubosPilotError(
|
|
48
|
+
'commit-all-paths-gitignored',
|
|
49
|
+
`All target paths are gitignored: ${paths.join(', ')}`,
|
|
50
|
+
{ paths },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (ignored.length > 0) {
|
|
54
|
+
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
`[nubos-pilot warn] gitignored (skipping): ${ignored.join(', ')}\n`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return paths.filter((p) => !ignored.includes(p));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commitTask(taskId, files, message) {
|
|
63
|
+
const committable = assertCommittablePaths(files);
|
|
64
|
+
if (committable.length === 0) {
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
throw new NubosPilotError(
|
|
69
|
+
'commit-no-paths',
|
|
70
|
+
'commitTask invoked with empty file list',
|
|
71
|
+
{ taskId },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
execFileSync('git', ['add', '--', ...committable], { stdio: 'pipe' });
|
|
75
|
+
execFileSync('git', ['commit', '-m', message, '--', ...committable], { stdio: 'pipe' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findCommitByTaskId(id) {
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if (typeof id !== 'string' || !TASK_ID_RE.test(id)) {
|
|
83
|
+
throw new NubosPilotError(
|
|
84
|
+
'task-commit-not-found',
|
|
85
|
+
`Invalid task id ${id}`,
|
|
86
|
+
{ id },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
const out = execFileSync(
|
|
94
|
+
'git',
|
|
95
|
+
[
|
|
96
|
+
'log',
|
|
97
|
+
'--all',
|
|
98
|
+
'--grep',
|
|
99
|
+
`^task(${id}):`,
|
|
100
|
+
'-n',
|
|
101
|
+
'1',
|
|
102
|
+
'--format=%H',
|
|
103
|
+
],
|
|
104
|
+
{ encoding: 'utf-8' },
|
|
105
|
+
).trim();
|
|
106
|
+
if (!out) {
|
|
107
|
+
throw new NubosPilotError(
|
|
108
|
+
'task-commit-not-found',
|
|
109
|
+
`No commit found for task ${id}`,
|
|
110
|
+
{ id },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function revertCommit(sha) {
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
execFileSync('git', ['revert', '--no-edit', sha], { stdio: 'pipe' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function restoreFiles(paths) {
|
|
124
|
+
if (!Array.isArray(paths) || paths.length === 0) return;
|
|
125
|
+
execFileSync('git', ['restore', '--', ...paths], { stdio: 'pipe' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkoutFromHead(paths, opts) {
|
|
129
|
+
if (!Array.isArray(paths) || paths.length === 0) return;
|
|
130
|
+
const cwd = opts && opts.cwd;
|
|
131
|
+
const args = cwd ? ['-C', cwd, 'checkout', 'HEAD', '--', ...paths]
|
|
132
|
+
: ['checkout', 'HEAD', '--', ...paths];
|
|
133
|
+
execFileSync('git', args, { stdio: 'pipe' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listTaskCommits(prefix) {
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if (typeof prefix !== 'string' || prefix.length === 0) {
|
|
140
|
+
throw new NubosPilotError(
|
|
141
|
+
'list-task-commits-invalid',
|
|
142
|
+
'listTaskCommits requires a non-empty phase or plan id prefix',
|
|
143
|
+
{ prefix },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const raw = execFileSync(
|
|
147
|
+
'git',
|
|
148
|
+
[
|
|
149
|
+
'log',
|
|
150
|
+
'--all',
|
|
151
|
+
'--grep',
|
|
152
|
+
`^task(${prefix}-`,
|
|
153
|
+
'--format=%H %s',
|
|
154
|
+
],
|
|
155
|
+
{ encoding: 'utf-8' },
|
|
156
|
+
);
|
|
157
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
158
|
+
return lines.map((line) => {
|
|
159
|
+
const sp = line.indexOf(' ');
|
|
160
|
+
if (sp < 0) return { sha: line, subject: '' };
|
|
161
|
+
return { sha: line.slice(0, sp), subject: line.slice(sp + 1) };
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function gitShowSafe(ref, filepath) {
|
|
166
|
+
try {
|
|
167
|
+
return execFileSync(
|
|
168
|
+
'git',
|
|
169
|
+
['show', ref + ':' + filepath],
|
|
170
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
171
|
+
);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err && err.status === 128) return null;
|
|
174
|
+
const stderr = String(err && err.stderr || '');
|
|
175
|
+
if (stderr.includes('exists on disk, but not in') || stderr.includes('does not exist in')) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function gitDiffNoColor(ref, filepath) {
|
|
183
|
+
try {
|
|
184
|
+
return execFileSync(
|
|
185
|
+
'git',
|
|
186
|
+
['--no-pager', 'diff', '--no-color', ref, '--', filepath],
|
|
187
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
188
|
+
);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err && typeof err.stdout === 'string') return err.stdout;
|
|
191
|
+
if (err && err.stdout !== undefined) return String(err.stdout);
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
commitTask,
|
|
198
|
+
assertCommittablePaths,
|
|
199
|
+
revertCommit,
|
|
200
|
+
restoreFiles,
|
|
201
|
+
checkoutFromHead,
|
|
202
|
+
findCommitByTaskId,
|
|
203
|
+
isPathIgnored,
|
|
204
|
+
listTaskCommits,
|
|
205
|
+
gitShowSafe,
|
|
206
|
+
gitDiffNoColor,
|
|
207
|
+
};
|
package/lib/git.test.cjs
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
const { test, after } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { execFileSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const git = require('./git.cjs');
|
|
9
|
+
|
|
10
|
+
const _repos = [];
|
|
11
|
+
|
|
12
|
+
function makeRepo() {
|
|
13
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-git-'));
|
|
14
|
+
execFileSync('git', ['init', '-q', '-b', 'main', root], { stdio: 'pipe' });
|
|
15
|
+
|
|
16
|
+
execFileSync('git', ['-C', root, 'config', 'user.email', 'test@nubos-pilot.local']);
|
|
17
|
+
execFileSync('git', ['-C', root, 'config', 'user.name', 'nubos-test']);
|
|
18
|
+
|
|
19
|
+
execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-q', '-m', 'chore: init'], {
|
|
20
|
+
stdio: 'pipe',
|
|
21
|
+
});
|
|
22
|
+
_repos.push(root);
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inRepo(root, fn) {
|
|
27
|
+
const prev = process.cwd();
|
|
28
|
+
process.chdir(root);
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
} finally {
|
|
32
|
+
process.chdir(prev);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeFile(root, rel, body) {
|
|
37
|
+
const p = path.join(root, rel);
|
|
38
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
39
|
+
fs.writeFileSync(p, body == null ? '' : body, 'utf-8');
|
|
40
|
+
return rel;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
after(() => {
|
|
44
|
+
while (_repos.length) {
|
|
45
|
+
const r = _repos.pop();
|
|
46
|
+
try { fs.rmSync(r, { recursive: true, force: true }); } catch {}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('GIT-1: assertCommittablePaths returns paths unchanged when none ignored', () => {
|
|
51
|
+
const root = makeRepo();
|
|
52
|
+
inRepo(root, () => {
|
|
53
|
+
writeFile(root, 'a.ts', 'x');
|
|
54
|
+
writeFile(root, 'b.ts', 'y');
|
|
55
|
+
const out = git.assertCommittablePaths(['a.ts', 'b.ts']);
|
|
56
|
+
assert.deepEqual(out, ['a.ts', 'b.ts']);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('GIT-2: assertCommittablePaths writes stderr warning for partial-ignored and skips ignored entries', () => {
|
|
61
|
+
const root = makeRepo();
|
|
62
|
+
inRepo(root, () => {
|
|
63
|
+
writeFile(root, '.gitignore', 'build/\n');
|
|
64
|
+
writeFile(root, 'a.ts', 'x');
|
|
65
|
+
writeFile(root, 'build/out.js', 'noise');
|
|
66
|
+
|
|
67
|
+
const original = process.stderr.write;
|
|
68
|
+
let captured = '';
|
|
69
|
+
process.stderr.write = (chunk) => {
|
|
70
|
+
captured += chunk;
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = git.assertCommittablePaths(['a.ts', 'build/out.js']);
|
|
76
|
+
} finally {
|
|
77
|
+
process.stderr.write = original;
|
|
78
|
+
}
|
|
79
|
+
assert.deepEqual(result, ['a.ts']);
|
|
80
|
+
assert.match(captured, /\[nubos-pilot warn\] gitignored \(skipping\):/);
|
|
81
|
+
assert.match(captured, /build\/out\.js/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('GIT-3: assertCommittablePaths throws commit-all-paths-gitignored when every path ignored (D-25)', () => {
|
|
86
|
+
const root = makeRepo();
|
|
87
|
+
inRepo(root, () => {
|
|
88
|
+
writeFile(root, '.gitignore', '.env\nsecret.txt\n');
|
|
89
|
+
writeFile(root, '.env', 'X=1');
|
|
90
|
+
writeFile(root, 'secret.txt', 'shh');
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => git.assertCommittablePaths(['.env', 'secret.txt']),
|
|
93
|
+
(err) => {
|
|
94
|
+
return err.name === 'NubosPilotError'
|
|
95
|
+
&& err.code === 'commit-all-paths-gitignored'
|
|
96
|
+
&& Array.isArray(err.details.paths)
|
|
97
|
+
&& err.details.paths.includes('.env');
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('GIT-4: isPathIgnored returns true for ignored, false for tracked-eligible', () => {
|
|
104
|
+
const root = makeRepo();
|
|
105
|
+
inRepo(root, () => {
|
|
106
|
+
writeFile(root, '.gitignore', 'node_modules/\n');
|
|
107
|
+
writeFile(root, 'node_modules/x.js', '');
|
|
108
|
+
writeFile(root, 'src.ts', '');
|
|
109
|
+
assert.equal(git.isPathIgnored('node_modules/x.js'), true);
|
|
110
|
+
assert.equal(git.isPathIgnored('src.ts'), false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('GIT-5: commitTask creates a single commit containing exactly the supplied paths', () => {
|
|
115
|
+
const root = makeRepo();
|
|
116
|
+
inRepo(root, () => {
|
|
117
|
+
writeFile(root, 'lib/git.cjs', '// stub');
|
|
118
|
+
git.commitTask('06-01-T01', ['lib/git.cjs'], 'task(06-01-T01): add git helper');
|
|
119
|
+
const log = execFileSync('git', ['log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
|
|
120
|
+
assert.equal(log, 'task(06-01-T01): add git helper');
|
|
121
|
+
const stat = execFileSync('git', ['show', '--stat', '--format=', 'HEAD'], { encoding: 'utf-8' });
|
|
122
|
+
assert.match(stat, /lib\/git\.cjs/);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('GIT-6: findCommitByTaskId returns 40-char SHA for known task commit', () => {
|
|
127
|
+
const root = makeRepo();
|
|
128
|
+
inRepo(root, () => {
|
|
129
|
+
writeFile(root, 'a.ts', 'x');
|
|
130
|
+
git.commitTask('06-01-T01', ['a.ts'], 'task(06-01-T01): add a.ts');
|
|
131
|
+
const sha = git.findCommitByTaskId('06-01-T01');
|
|
132
|
+
assert.match(sha, /^[0-9a-f]{40}$/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('GIT-7: findCommitByTaskId throws task-commit-not-found when no commit matches', () => {
|
|
137
|
+
const root = makeRepo();
|
|
138
|
+
inRepo(root, () => {
|
|
139
|
+
assert.throws(
|
|
140
|
+
() => git.findCommitByTaskId('06-01-T99'),
|
|
141
|
+
(err) => err.code === 'task-commit-not-found' && err.details.id === '06-01-T99',
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('GIT-8: findCommitByTaskId rejects malformed task-id BEFORE --grep embedding (regex injection guard)', () => {
|
|
147
|
+
const root = makeRepo();
|
|
148
|
+
inRepo(root, () => {
|
|
149
|
+
assert.throws(
|
|
150
|
+
() => git.findCommitByTaskId('invalid-id'),
|
|
151
|
+
(err) => err.code === 'task-commit-not-found',
|
|
152
|
+
);
|
|
153
|
+
assert.throws(
|
|
154
|
+
() => git.findCommitByTaskId('06-01-T01.*'),
|
|
155
|
+
(err) => err.code === 'task-commit-not-found',
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('GIT-9: findCommitByTaskId is anchored — body-mention of stale task-id does not produce false match (Pitfall 3)', () => {
|
|
161
|
+
const root = makeRepo();
|
|
162
|
+
inRepo(root, () => {
|
|
163
|
+
|
|
164
|
+
writeFile(root, 'a.ts', 'x');
|
|
165
|
+
git.commitTask('06-01-T01', ['a.ts'], 'task(06-01-T01): real task');
|
|
166
|
+
const realSha = git.findCommitByTaskId('06-01-T01');
|
|
167
|
+
|
|
168
|
+
writeFile(root, 'b.ts', 'y');
|
|
169
|
+
execFileSync('git', ['add', '--', 'b.ts']);
|
|
170
|
+
execFileSync('git', [
|
|
171
|
+
'commit',
|
|
172
|
+
'-m',
|
|
173
|
+
'task(06-01-T02): something',
|
|
174
|
+
'-m',
|
|
175
|
+
'See also task(06-01-T01) which we extended here.',
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const t1 = git.findCommitByTaskId('06-01-T01');
|
|
179
|
+
const t2 = git.findCommitByTaskId('06-01-T02');
|
|
180
|
+
assert.equal(t1, realSha, 'T01 must still resolve to the original commit, not the body-mention');
|
|
181
|
+
assert.notEqual(t1, t2);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('GIT-10: revertCommit creates a forward revert commit (no history rewrite)', () => {
|
|
186
|
+
const root = makeRepo();
|
|
187
|
+
inRepo(root, () => {
|
|
188
|
+
writeFile(root, 'a.ts', 'x');
|
|
189
|
+
git.commitTask('06-01-T01', ['a.ts'], 'task(06-01-T01): add a.ts');
|
|
190
|
+
const before = execFileSync('git', ['rev-list', '--count', 'HEAD'], { encoding: 'utf-8' }).trim();
|
|
191
|
+
const sha = git.findCommitByTaskId('06-01-T01');
|
|
192
|
+
git.revertCommit(sha);
|
|
193
|
+
const after = execFileSync('git', ['rev-list', '--count', 'HEAD'], { encoding: 'utf-8' }).trim();
|
|
194
|
+
assert.equal(Number(after), Number(before) + 1, 'revert must add a new commit, not rewrite history');
|
|
195
|
+
|
|
196
|
+
const stillThere = execFileSync('git', ['cat-file', '-t', sha], { encoding: 'utf-8' }).trim();
|
|
197
|
+
assert.equal(stillThere, 'commit');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('GIT-11: restoreFiles resets working-tree changes for the given paths', () => {
|
|
202
|
+
const root = makeRepo();
|
|
203
|
+
inRepo(root, () => {
|
|
204
|
+
writeFile(root, 'a.ts', 'original');
|
|
205
|
+
git.commitTask('06-01-T01', ['a.ts'], 'task(06-01-T01): add a.ts');
|
|
206
|
+
fs.writeFileSync(path.join(root, 'a.ts'), 'mutated', 'utf-8');
|
|
207
|
+
git.restoreFiles(['a.ts']);
|
|
208
|
+
const content = fs.readFileSync(path.join(root, 'a.ts'), 'utf-8');
|
|
209
|
+
assert.equal(content, 'original');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('GIT-12: listTaskCommits returns parsed array of {sha, subject} for a plan-id prefix', () => {
|
|
214
|
+
const root = makeRepo();
|
|
215
|
+
inRepo(root, () => {
|
|
216
|
+
writeFile(root, 'a.ts', 'x');
|
|
217
|
+
git.commitTask('06-01-T01', ['a.ts'], 'task(06-01-T01): first');
|
|
218
|
+
writeFile(root, 'b.ts', 'y');
|
|
219
|
+
git.commitTask('06-01-T02', ['b.ts'], 'task(06-01-T02): second');
|
|
220
|
+
const list = git.listTaskCommits('06-01');
|
|
221
|
+
assert.equal(list.length, 2);
|
|
222
|
+
for (const entry of list) {
|
|
223
|
+
assert.match(entry.sha, /^[0-9a-f]{40}$/);
|
|
224
|
+
assert.match(entry.subject, /^task\(06-01-T0[12]\):/);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
function commitFile(root, rel, body, msg) {
|
|
230
|
+
writeFile(root, rel, body);
|
|
231
|
+
execFileSync('git', ['-C', root, 'add', '--', rel], { stdio: 'pipe' });
|
|
232
|
+
execFileSync('git', ['-C', root, 'commit', '-q', '-m', msg], { stdio: 'pipe' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test('GIT-SHOW-1: gitShowSafe returns file body for committed path', () => {
|
|
236
|
+
const root = makeRepo();
|
|
237
|
+
inRepo(root, () => {
|
|
238
|
+
commitFile(root, 'README.md', 'hello world\n', 'chore: add README');
|
|
239
|
+
const body = git.gitShowSafe('HEAD', 'README.md');
|
|
240
|
+
assert.equal(body, 'hello world\n');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('GIT-SHOW-2: gitShowSafe returns null for non-existent path (Pitfall 5 exit-128)', () => {
|
|
245
|
+
const root = makeRepo();
|
|
246
|
+
inRepo(root, () => {
|
|
247
|
+
commitFile(root, 'README.md', 'x\n', 'chore: seed');
|
|
248
|
+
const body = git.gitShowSafe('HEAD', 'no-such-file.md');
|
|
249
|
+
assert.equal(body, null);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('GIT-SHOW-3: gitShowSafe returns null for path not yet in HEAD (uncommitted rename case)', () => {
|
|
254
|
+
const root = makeRepo();
|
|
255
|
+
inRepo(root, () => {
|
|
256
|
+
commitFile(root, 'a.md', 'alpha\n', 'chore: seed');
|
|
257
|
+
const body = git.gitShowSafe('HEAD', '.planning/phases/09-feature-set/09-01-PLAN.md');
|
|
258
|
+
assert.equal(body, null);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('GIT-SHOW-4: gitShowSafe returns null for git-repo-missing case (pragmatic extension of Pitfall 5 semantics)', () => {
|
|
263
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'np-git-noregion-'));
|
|
264
|
+
const prev = process.cwd();
|
|
265
|
+
process.chdir(tmp);
|
|
266
|
+
try {
|
|
267
|
+
assert.equal(git.gitShowSafe('HEAD', 'any.md'), null);
|
|
268
|
+
} finally {
|
|
269
|
+
process.chdir(prev);
|
|
270
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('GIT-DIFF-1: gitDiffNoColor returns diff body starting with "diff --git" after mutation', () => {
|
|
275
|
+
const root = makeRepo();
|
|
276
|
+
inRepo(root, () => {
|
|
277
|
+
commitFile(root, 'README.md', 'original\n', 'chore: seed');
|
|
278
|
+
fs.writeFileSync(path.join(root, 'README.md'), 'modified\n', 'utf-8');
|
|
279
|
+
const diff = git.gitDiffNoColor('HEAD', 'README.md');
|
|
280
|
+
assert.ok(diff.startsWith('diff --git'), 'expected diff header at start, got: ' + diff.slice(0, 40));
|
|
281
|
+
assert.ok(diff.indexOf('-original') >= 0);
|
|
282
|
+
assert.ok(diff.indexOf('+modified') >= 0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('GIT-DIFF-2: gitDiffNoColor returns empty string when working tree matches HEAD', () => {
|
|
287
|
+
const root = makeRepo();
|
|
288
|
+
inRepo(root, () => {
|
|
289
|
+
commitFile(root, 'README.md', 'same\n', 'chore: seed');
|
|
290
|
+
const diff = git.gitDiffNoColor('HEAD', 'README.md');
|
|
291
|
+
assert.equal(diff, '');
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('GIT-DIFF-3: gitDiffNoColor output strips ANSI even with color.ui=always (Pitfall 6)', () => {
|
|
296
|
+
const root = makeRepo();
|
|
297
|
+
inRepo(root, () => {
|
|
298
|
+
commitFile(root, 'README.md', 'red\n', 'chore: seed');
|
|
299
|
+
execFileSync('git', ['-C', root, 'config', '--local', 'color.ui', 'always'], { stdio: 'pipe' });
|
|
300
|
+
fs.writeFileSync(path.join(root, 'README.md'), 'green\n', 'utf-8');
|
|
301
|
+
const diff = git.gitDiffNoColor('HEAD', 'README.md');
|
|
302
|
+
assert.ok(diff.length > 0);
|
|
303
|
+
assert.equal(diff.indexOf('\x1b'), -1, 'output must contain no ESC bytes');
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { extractFrontmatter } = require('../frontmatter.cjs');
|
|
2
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_RUNTIME = 'codex';
|
|
5
|
+
|
|
6
|
+
const FRONTMATTER_RE = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/;
|
|
7
|
+
const PERMISSION_MODE_LINE_RE = /^[ \t]*permissionMode[ \t]*:.*(?:\r?\n|$)/m;
|
|
8
|
+
|
|
9
|
+
function _stripPermissionMode(frontmatterBody) {
|
|
10
|
+
return frontmatterBody.replace(PERMISSION_MODE_LINE_RE, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function _resolveNotice(runtime) {
|
|
14
|
+
const { getAdapter } = require('../runtime/index.cjs');
|
|
15
|
+
const adapter = getAdapter(runtime);
|
|
16
|
+
if (typeof adapter.runtimeNotice !== 'string' || adapter.runtimeNotice.length === 0) {
|
|
17
|
+
throw new NubosPilotError(
|
|
18
|
+
'agents-md-missing-notice',
|
|
19
|
+
'Adapter for ' + runtime + ' has no runtimeNotice export',
|
|
20
|
+
{ runtime },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return adapter.runtimeNotice;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function generateAgentsMd(claudeMdContent, runtime) {
|
|
27
|
+
if (typeof claudeMdContent !== 'string') {
|
|
28
|
+
throw new NubosPilotError(
|
|
29
|
+
'agents-md-invalid-input',
|
|
30
|
+
'generateAgentsMd expects a string',
|
|
31
|
+
{ got: typeof claudeMdContent },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rt = runtime || DEFAULT_RUNTIME;
|
|
36
|
+
const runtimeNotice = _resolveNotice(rt);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
extractFrontmatter(claudeMdContent);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new NubosPilotError(
|
|
42
|
+
'agents-md-invalid-input',
|
|
43
|
+
'generateAgentsMd could not parse frontmatter',
|
|
44
|
+
{ cause: err && err.code ? err.code : String(err) },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fmMatch = claudeMdContent.match(FRONTMATTER_RE);
|
|
49
|
+
|
|
50
|
+
if (fmMatch) {
|
|
51
|
+
const openDelim = fmMatch[1];
|
|
52
|
+
const innerYaml = fmMatch[2];
|
|
53
|
+
const closeDelim = fmMatch[3];
|
|
54
|
+
const body = claudeMdContent.slice(fmMatch[0].length);
|
|
55
|
+
|
|
56
|
+
const stripped = _stripPermissionMode(innerYaml);
|
|
57
|
+
const trimmedInner = stripped.replace(/(?:\r?\n)+$/, '');
|
|
58
|
+
|
|
59
|
+
let rebuilt;
|
|
60
|
+
if (trimmedInner.trim() === '') {
|
|
61
|
+
rebuilt = '';
|
|
62
|
+
} else {
|
|
63
|
+
rebuilt = openDelim + trimmedInner + closeDelim;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const separator = rebuilt.length === 0
|
|
67
|
+
? ''
|
|
68
|
+
: (rebuilt.endsWith('\n') ? '' : '\n');
|
|
69
|
+
const bodyPrefix = body.startsWith('\n') ? '' : '\n';
|
|
70
|
+
return rebuilt + separator + runtimeNotice + '\n' + bodyPrefix + body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bodyPrefix = claudeMdContent.startsWith('\n') ? '' : '\n';
|
|
74
|
+
return runtimeNotice + '\n' + bodyPrefix + claudeMdContent;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { generateAgentsMd };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
3
|
+
|
|
4
|
+
const MAX_NUMBERED_BACKUPS = 99;
|
|
5
|
+
|
|
6
|
+
function _refuseSymlink(filePath) {
|
|
7
|
+
let st;
|
|
8
|
+
try {
|
|
9
|
+
st = fs.lstatSync(filePath);
|
|
10
|
+
} catch (err) {
|
|
11
|
+
throw new NubosPilotError(
|
|
12
|
+
'backup-source-missing',
|
|
13
|
+
'Cannot stat file to back up: ' + (err && err.message),
|
|
14
|
+
{ filePath },
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
if (st.isSymbolicLink()) {
|
|
18
|
+
throw new NubosPilotError(
|
|
19
|
+
'backup-refuses-symlink',
|
|
20
|
+
'Refusing to back up a symlink (would dereference target): ' + filePath,
|
|
21
|
+
{ filePath },
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function backupFile(filePath) {
|
|
27
|
+
_refuseSymlink(filePath);
|
|
28
|
+
const base = filePath + '.bak';
|
|
29
|
+
if (!fs.existsSync(base)) {
|
|
30
|
+
try {
|
|
31
|
+
fs.renameSync(filePath, base);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new NubosPilotError(
|
|
34
|
+
'backup-rename-failed',
|
|
35
|
+
'Cannot rename to .bak: ' + (err && err.message),
|
|
36
|
+
{ filePath, target: base },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return base;
|
|
40
|
+
}
|
|
41
|
+
for (let n = 1; n <= MAX_NUMBERED_BACKUPS; n++) {
|
|
42
|
+
const candidate = `${filePath}.bak.${n}`;
|
|
43
|
+
if (!fs.existsSync(candidate)) {
|
|
44
|
+
try {
|
|
45
|
+
fs.renameSync(filePath, candidate);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new NubosPilotError(
|
|
48
|
+
'backup-rename-failed',
|
|
49
|
+
'Cannot rename to ' + candidate + ': ' + (err && err.message),
|
|
50
|
+
{ filePath, target: candidate },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
57
|
+
const fallback = `${filePath}.bak.orphan-${ts}`;
|
|
58
|
+
try {
|
|
59
|
+
fs.renameSync(filePath, fallback);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new NubosPilotError(
|
|
62
|
+
'backup-rename-failed',
|
|
63
|
+
'Cannot rename to orphan backup: ' + (err && err.message),
|
|
64
|
+
{ filePath, target: fallback },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { backupFile, MAX_NUMBERED_BACKUPS };
|