@deploybuddy/node-adapter 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeployBuddy contributors
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 CHANGED
@@ -1,3 +1,12 @@
1
- # Temporary Holding Version
1
+ # @deploybuddy/node-adapter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read-only Node.js filesystem and Git evidence collection for DeployBuddy. Git is invoked through argument arrays with bounded execution; the adapter does not mutate repository state.
4
+
5
+ ```ts
6
+ import { NodeWorkspaceAdapter } from '@deploybuddy/node-adapter';
7
+
8
+ const adapter = new NodeWorkspaceAdapter(process.cwd());
9
+ const git = await adapter.getGitSignals();
10
+ ```
11
+
12
+ Build with `npm run build`. See the monorepo [architecture and compatibility policy](../../docs/architecture.md).
@@ -0,0 +1,28 @@
1
+ import { type ChangedFile, type CheckResult, type ConfigParseResult, type ContextSignals, type DiagnosticSignals, type GitSignals, type PolicyParseResult, type ProjectMarker, type ProjectProfile, type TestSignals, type WorkspaceAdapter } from '@deploybuddy/core';
2
+ export declare class NodeWorkspaceAdapter implements WorkspaceAdapter {
3
+ readonly cwd: string;
4
+ private markersPromise?;
5
+ private configTextPromise?;
6
+ private workingChangedFiles?;
7
+ constructor(cwd?: string);
8
+ beginCollection(): void;
9
+ getWorkspaceFolders(): Promise<string[]>;
10
+ getDiagnostics(): Promise<DiagnosticSignals>;
11
+ getTestSignals(): Promise<TestSignals>;
12
+ getContext(): Promise<ContextSignals>;
13
+ getGitSignals(): Promise<GitSignals>;
14
+ getChangedFiles(range?: {
15
+ from: string;
16
+ to: string;
17
+ } | {
18
+ base: string;
19
+ }): Promise<ChangedFile[]>;
20
+ private resolveRevision;
21
+ getProjectProfile(): Promise<ProjectProfile>;
22
+ getCheckResults(): Promise<CheckResult[]>;
23
+ getConfig(): Promise<ConfigParseResult>;
24
+ getPolicy(): Promise<PolicyParseResult>;
25
+ private getConfigText;
26
+ getMarkers(): Promise<ProjectMarker[]>;
27
+ private readMarkers;
28
+ }
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.NodeWorkspaceAdapter = void 0;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = require("node:fs");
9
+ const promises_1 = require("node:fs/promises");
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_util_1 = require("node:util");
12
+ const core_1 = require("@deploybuddy/core");
13
+ const execFile = (0, node_util_1.promisify)(node_child_process_1.execFile);
14
+ const GIT_TIMEOUT_MS = 10_000;
15
+ const MAX_CONFIG_BYTES = 256 * 1024;
16
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
17
+ const excluded = new Set(['.git', 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.next', '.nuxt', 'target']);
18
+ const unavailableGit = () => ({ available: false, repositories: [], branch: null, upstream: null, ahead: null, behind: null, modifiedFiles: 0, stagedFiles: 0, untrackedFiles: 0, changedFiles: 0, unstagedFiles: null, conflictFiles: null, detachedHead: null, hasUncommittedChanges: false, dirtyPrimaryBranch: false, note: 'Git is unavailable or this directory is not a Git repository.' });
19
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
20
+ const keys = (value) => Object.keys(object(value));
21
+ const stringMap = (value) => Object.fromEntries(Object.entries(object(value)).filter((entry) => typeof entry[1] === 'string'));
22
+ class NodeWorkspaceAdapter {
23
+ cwd;
24
+ markersPromise;
25
+ configTextPromise;
26
+ workingChangedFiles;
27
+ constructor(cwd = process.cwd()) { this.cwd = node_path_1.default.resolve(cwd); }
28
+ beginCollection() { this.markersPromise = undefined; this.configTextPromise = undefined; this.workingChangedFiles = undefined; }
29
+ async getWorkspaceFolders() { return ['.']; }
30
+ async getDiagnostics() { return { available: false, errors: 0, warnings: 0, note: 'Node CLI has no language-server diagnostics. Use your project lint/build checks.' }; }
31
+ async getTestSignals() { return { testStatus: 'unknown', passed: null, failed: null, skipped: null, notRun: null, note: 'Test results have not been collected. Available test commands are listed by `deploybuddy checks`.' }; }
32
+ async getContext() {
33
+ const now = new Date();
34
+ return { day: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getDay()] ?? 'Unknown', dayOfWeek: now.getDay(), hour: now.getHours(), minute: now.getMinutes(), timestamp: now.toISOString() };
35
+ }
36
+ async getGitSignals() {
37
+ try {
38
+ const { stdout } = await execFile('git', ['--no-optional-locks', 'status', '--porcelain=v1', '--branch', '--untracked-files=all', '-z'], { cwd: this.cwd, maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS, windowsHide: true });
39
+ const entries = stdout.split('\0').filter(Boolean);
40
+ const header = entries.shift() ?? '';
41
+ const match = /^## (.*)$/.exec(header);
42
+ const branchText = match?.[1] ?? '';
43
+ const detached = /\(HEAD detached|HEAD \(no branch\)/i.test(branchText);
44
+ const aheadBehind = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/.exec(branchText);
45
+ const upstreamMatch = /\.\.\.([^ ]+)/.exec(branchText);
46
+ const branch = detached ? null : branchText.replace(/\.\.\..*$/, '').replace(/ \[.*$/, '').replace(/^No commits yet on /, '') || null;
47
+ let revision;
48
+ try {
49
+ const [{ stdout: headOutput }, { stdout: branchOutput }] = await Promise.all([
50
+ execFile('git', ['rev-parse', '--verify', 'HEAD'], { cwd: this.cwd, maxBuffer: 4096, timeout: GIT_TIMEOUT_MS, windowsHide: true }),
51
+ execFile('git', ['branch', '--show-current'], { cwd: this.cwd, maxBuffer: 4096, timeout: GIT_TIMEOUT_MS, windowsHide: true }),
52
+ ]);
53
+ const sha = headOutput.trim().toLowerCase();
54
+ if (/^[a-f0-9]{40,64}$/.test(sha))
55
+ revision = { sha, shortSha: sha.slice(0, 7), ...(branchOutput.trim() ? { branch: branchOutput.trim().slice(0, 255) } : {}), detached };
56
+ }
57
+ catch { /* Keep usable repository status when HEAD identity is unavailable. */ }
58
+ const upstream = upstreamMatch?.[1] ?? null;
59
+ const ahead = upstream ? Number(aheadBehind?.[1] ?? 0) : null;
60
+ const behind = upstream ? Number(aheadBehind?.[2] ?? 0) : null;
61
+ const changedPaths = new Set();
62
+ const fileChanges = [];
63
+ let stagedFiles = 0;
64
+ let unstagedFiles = 0;
65
+ let untrackedFiles = 0;
66
+ let conflictFiles = 0;
67
+ for (let i = 0; i < entries.length; i++) {
68
+ const item = entries[i] ?? '';
69
+ const xy = item.slice(0, 2);
70
+ const file = item.slice(3);
71
+ if (xy === '??') {
72
+ untrackedFiles++;
73
+ fileChanges.push({ path: file, status: 'added' });
74
+ }
75
+ else {
76
+ changedPaths.add(file);
77
+ fileChanges.push({ path: file, status: xy.includes('R') || xy.includes('C') ? 'renamed' : xy.includes('A') ? 'added' : xy.includes('D') ? 'deleted' : xy.trim() ? 'modified' : 'unknown' });
78
+ if (xy[0] !== ' ')
79
+ stagedFiles++;
80
+ if (xy[1] !== ' ')
81
+ unstagedFiles++;
82
+ if (['UU', 'AA', 'DD', 'AU', 'UA', 'DU', 'UD'].includes(xy))
83
+ conflictFiles++;
84
+ if (xy.includes('R') || xy.includes('C'))
85
+ i++;
86
+ }
87
+ }
88
+ this.workingChangedFiles = (0, core_1.normalizeChangedFiles)(fileChanges);
89
+ const changedFiles = changedPaths.size + untrackedFiles;
90
+ const hasUncommittedChanges = changedFiles > 0;
91
+ const repository = { root: '.', branch, upstream, ahead, behind, unstagedFiles, conflictFiles, detachedHead: detached, modifiedFiles: changedPaths.size, stagedFiles, untrackedFiles, changedFiles, hasUncommittedChanges, ...(revision ? { revision } : {}) };
92
+ return { available: true, repositories: [repository], branch, upstream, ahead, behind, unstagedFiles, conflictFiles, detachedHead: detached, modifiedFiles: repository.modifiedFiles, stagedFiles, untrackedFiles, changedFiles, hasUncommittedChanges, dirtyPrimaryBranch: hasUncommittedChanges && (branch === 'main' || branch === 'master'), ...(revision ? { revision } : {}) };
93
+ }
94
+ catch {
95
+ this.workingChangedFiles = [];
96
+ return unavailableGit();
97
+ }
98
+ }
99
+ async getChangedFiles(range) {
100
+ try {
101
+ if (range && 'from' in range) {
102
+ const from = await this.resolveRevision(range.from);
103
+ const to = await this.resolveRevision(range.to);
104
+ const { stdout } = await execFile('git', ['--no-optional-locks', 'diff', '--name-status', '-z', '--no-renames', `${from}...${to}`], { cwd: this.cwd, maxBuffer: 4 * 1024 * 1024, timeout: GIT_TIMEOUT_MS, windowsHide: true });
105
+ return parseNameStatus(stdout);
106
+ }
107
+ if (range && 'base' in range) {
108
+ const base = await this.resolveRevision(range.base);
109
+ const { stdout } = await execFile('git', ['--no-optional-locks', 'diff', '--name-status', '-z', '--no-renames', `${base}...HEAD`], { cwd: this.cwd, maxBuffer: 4 * 1024 * 1024, timeout: GIT_TIMEOUT_MS, windowsHide: true });
110
+ return parseNameStatus(stdout);
111
+ }
112
+ if (this.workingChangedFiles)
113
+ return this.workingChangedFiles;
114
+ const { stdout } = await execFile('git', ['--no-optional-locks', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: this.cwd, maxBuffer: 4 * 1024 * 1024, timeout: GIT_TIMEOUT_MS, windowsHide: true });
115
+ const parts = stdout.split('\0').filter(Boolean);
116
+ const files = [];
117
+ for (let i = 0; i < parts.length; i++) {
118
+ const record = parts[i] ?? '';
119
+ const status = record.slice(0, 2);
120
+ const filePath = record.slice(3);
121
+ if (status === '??')
122
+ files.push({ path: filePath, status: 'added' });
123
+ else {
124
+ const normalizedStatus = status.includes('R') || status.includes('C') ? 'renamed' : status.includes('A') ? 'added' : status.includes('D') ? 'deleted' : status.trim() ? 'modified' : 'unknown';
125
+ files.push({ path: filePath, status: normalizedStatus });
126
+ if (status.includes('R') || status.includes('C'))
127
+ i++;
128
+ }
129
+ }
130
+ return (0, core_1.normalizeChangedFiles)(files);
131
+ }
132
+ catch (error) {
133
+ if (range)
134
+ throw new Error(`Could not inspect the requested Git change range. Check that the revision exists and is reachable locally.`, { cause: error });
135
+ return [];
136
+ }
137
+ }
138
+ async resolveRevision(revision) {
139
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(revision) || revision.includes('..') || revision.includes('//') || revision.startsWith('-') || revision.endsWith('/') || revision.includes('@{'))
140
+ throw new Error('Invalid Git revision.');
141
+ const { stdout } = await execFile('git', ['--no-optional-locks', 'rev-parse', '--verify', `${revision}^{commit}`], { cwd: this.cwd, maxBuffer: 4096, timeout: GIT_TIMEOUT_MS, windowsHide: true });
142
+ const resolved = stdout.trim().toLowerCase();
143
+ if (!/^[a-f0-9]{40,64}$/.test(resolved))
144
+ throw new Error('Git revision could not be resolved.');
145
+ return resolved;
146
+ }
147
+ async getProjectProfile() { return (0, core_1.detectProjectProfile)(await this.getMarkers(), true); }
148
+ async getCheckResults() { return (0, core_1.detectChecks)(await this.getMarkers(), true); }
149
+ async getConfig() {
150
+ try {
151
+ return (0, core_1.parseDeployBuddyConfig)(await this.getConfigText());
152
+ }
153
+ catch (error) {
154
+ if (isNodeError(error) && error.code === 'ENOENT')
155
+ return (0, core_1.parseDeployBuddyConfig)(undefined);
156
+ if (isNodeError(error) && error.code === 'EFBIG')
157
+ return (0, core_1.parseDeployBuddyConfig)(' '.repeat(MAX_CONFIG_BYTES + 1));
158
+ return (0, core_1.parseDeployBuddyConfig)('{');
159
+ }
160
+ }
161
+ async getPolicy() {
162
+ try {
163
+ return (0, core_1.parseDeployBuddyPolicy)(await this.getConfigText());
164
+ }
165
+ catch (error) {
166
+ if (isNodeError(error) && error.code === 'ENOENT')
167
+ return (0, core_1.parseDeployBuddyPolicy)(undefined);
168
+ if (isNodeError(error) && error.code === 'EFBIG')
169
+ return (0, core_1.parseDeployBuddyPolicy)(' '.repeat(MAX_CONFIG_BYTES + 1));
170
+ return (0, core_1.parseDeployBuddyPolicy)('{');
171
+ }
172
+ }
173
+ getConfigText() { this.configTextPromise ??= readOptionalBoundedText(this.cwd, '.deploybuddy.json', MAX_CONFIG_BYTES); return this.configTextPromise; }
174
+ async getMarkers() {
175
+ this.markersPromise ??= this.readMarkers();
176
+ return this.markersPromise;
177
+ }
178
+ async readMarkers() {
179
+ const roots = [{ absolute: this.cwd, relative: './' }];
180
+ try {
181
+ const entries = await (0, promises_1.readdir)(this.cwd, { withFileTypes: true });
182
+ for (const entry of entries)
183
+ if (entry.isDirectory() && !entry.name.startsWith('.') && !excluded.has(entry.name))
184
+ roots.push({ absolute: node_path_1.default.join(this.cwd, entry.name), relative: `./${safeSegment(entry.name)}` });
185
+ }
186
+ catch {
187
+ return [];
188
+ }
189
+ const markers = [];
190
+ for (const root of roots) {
191
+ let files;
192
+ try {
193
+ files = await (0, promises_1.readdir)(root.absolute);
194
+ }
195
+ catch {
196
+ continue;
197
+ }
198
+ if (!files.includes('package.json') && !files.includes('composer.json') && !files.includes('artisan') && !files.some(name => /^vite\.config\./.test(name)))
199
+ continue;
200
+ const marker = { root: root.relative, files, packageManagerFiles: files.filter(name => /^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/.test(name)) };
201
+ if (files.includes('package.json')) {
202
+ try {
203
+ const data = JSON.parse(await readBoundedText(root.absolute, 'package.json', MAX_MANIFEST_BYTES));
204
+ const manifest = object(data);
205
+ marker.packageDependencies = [...keys(manifest.dependencies), ...keys(manifest.devDependencies), ...keys(manifest.optionalDependencies)];
206
+ marker.packageScripts = stringMap(manifest.scripts);
207
+ marker.packageManager = typeof manifest.packageManager === 'string' ? manifest.packageManager : undefined;
208
+ }
209
+ catch {
210
+ marker.parseErrors = [`Could not parse package.json in ${root.relative}`];
211
+ }
212
+ }
213
+ if (files.includes('composer.json')) {
214
+ try {
215
+ const manifest = object(JSON.parse(await readBoundedText(root.absolute, 'composer.json', MAX_MANIFEST_BYTES)));
216
+ marker.composerDependencies = [...keys(manifest.require), ...keys(manifest['require-dev'])];
217
+ marker.composerScripts = stringMap(manifest.scripts);
218
+ }
219
+ catch {
220
+ marker.parseErrors = [...(marker.parseErrors ?? []), `Could not parse composer.json in ${root.relative}`];
221
+ }
222
+ }
223
+ markers.push(marker);
224
+ }
225
+ return markers;
226
+ }
227
+ }
228
+ exports.NodeWorkspaceAdapter = NodeWorkspaceAdapter;
229
+ function parseNameStatus(output) {
230
+ const parts = output.split('\0');
231
+ const files = [];
232
+ for (let i = 0; i < parts.length;) {
233
+ const code = parts[i++] ?? '';
234
+ if (!code)
235
+ continue;
236
+ const filePath = parts[i++] ?? '';
237
+ const status = code[0] === 'A' ? 'added' : code[0] === 'D' ? 'deleted' : code[0] === 'R' || code[0] === 'C' ? 'renamed' : code[0] === 'M' || code[0] === 'T' ? 'modified' : 'unknown';
238
+ files.push({ path: filePath, status });
239
+ if (code[0] === 'R' || code[0] === 'C')
240
+ i++;
241
+ }
242
+ return (0, core_1.normalizeChangedFiles)(files);
243
+ }
244
+ function isNodeError(error) { return error instanceof Error && 'code' in error; }
245
+ function safeSegment(value) { return value.replace(/[^a-z0-9._ -]/gi, '_').slice(0, 64) || '_'; }
246
+ async function readOptionalBoundedText(directory, name, limit) {
247
+ try {
248
+ return await readBoundedText(directory, name, limit);
249
+ }
250
+ catch (error) {
251
+ if (isNodeError(error) && error.code === 'ENOENT')
252
+ return undefined;
253
+ throw error;
254
+ }
255
+ }
256
+ async function readBoundedText(directory, name, limit) {
257
+ const filename = node_path_1.default.join(directory, name);
258
+ const before = await (0, promises_1.lstat)(filename);
259
+ if (!before.isFile() || before.isSymbolicLink())
260
+ throw Object.assign(new Error('Expected a regular workspace file.'), { code: 'EINVAL' });
261
+ if (before.size > limit)
262
+ throw Object.assign(new Error('Workspace metadata file exceeds the size limit.'), { code: 'EFBIG' });
263
+ const noFollow = typeof node_fs_1.constants.O_NOFOLLOW === 'number' ? node_fs_1.constants.O_NOFOLLOW : 0;
264
+ const handle = await (0, promises_1.open)(filename, node_fs_1.constants.O_RDONLY | noFollow);
265
+ try {
266
+ const after = await handle.stat();
267
+ if (!after.isFile() || after.size > limit || before.dev !== after.dev || before.ino !== after.ino)
268
+ throw Object.assign(new Error('Workspace metadata file changed during inspection or exceeds the size limit.'), { code: 'EFBIG' });
269
+ return await handle.readFile({ encoding: 'utf8' });
270
+ }
271
+ finally {
272
+ await handle.close();
273
+ }
274
+ }
@@ -0,0 +1 @@
1
+ export { NodeWorkspaceAdapter } from './NodeWorkspaceAdapter';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NodeWorkspaceAdapter = void 0;
4
+ var NodeWorkspaceAdapter_1 = require("./NodeWorkspaceAdapter");
5
+ Object.defineProperty(exports, "NodeWorkspaceAdapter", { enumerable: true, get: function () { return NodeWorkspaceAdapter_1.NodeWorkspaceAdapter; } });
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@deploybuddy/node-adapter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Read-only Node.js workspace inspection for DeployBuddy.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/prestone2/deploybuddy.git",
27
+ "directory": "packages/node-adapter"
28
+ },
29
+ "homepage": "https://github.com/prestone2/deploybuddy#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/prestone2/deploybuddy/issues"
32
+ },
33
+ "keywords": [
34
+ "deployment",
35
+ "git",
36
+ "workspace",
37
+ "node"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "test": "npm run build && node --test test/*.test.cjs"
42
+ },
43
+ "dependencies": {
44
+ "@deploybuddy/core": "file:../core"
45
+ }
46
+ }