lua-cli 3.15.1 → 3.15.3
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/dist/api/skills.api.service.d.ts +8 -0
- package/dist/api/skills.api.service.d.ts.map +1 -1
- package/dist/api/skills.api.service.js +8 -0
- package/dist/api/skills.api.service.js.map +1 -1
- package/dist/cli/command-definitions.d.ts.map +1 -1
- package/dist/cli/command-definitions.js +8 -0
- package/dist/cli/command-definitions.js.map +1 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +13 -6
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/push.d.ts +12 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +125 -8
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/source.d.ts.map +1 -1
- package/dist/commands/source.js +17 -7
- package/dist/commands/source.js.map +1 -1
- package/dist/compiler/plugins/agent.plugin.d.ts.map +1 -1
- package/dist/compiler/plugins/agent.plugin.js +2 -0
- package/dist/compiler/plugins/agent.plugin.js.map +1 -1
- package/dist/compiler/utils/common.d.ts +3 -19
- package/dist/compiler/utils/common.d.ts.map +1 -1
- package/dist/compiler/utils/common.js +8 -47
- package/dist/compiler/utils/common.js.map +1 -1
- package/dist/interfaces/backup.d.ts +6 -132
- package/dist/interfaces/backup.d.ts.map +1 -1
- package/dist/interfaces/backup.js +3 -4
- package/dist/interfaces/backup.js.map +1 -1
- package/dist/utils/backup-helpers.d.ts +6 -58
- package/dist/utils/backup-helpers.d.ts.map +1 -1
- package/dist/utils/backup-helpers.js +35 -158
- package/dist/utils/backup-helpers.js.map +1 -1
- package/dist/utils/build-manifest-from-disk.d.ts +3 -12
- package/dist/utils/build-manifest-from-disk.d.ts.map +1 -1
- package/dist/utils/build-manifest-from-disk.js +10 -100
- package/dist/utils/build-manifest-from-disk.js.map +1 -1
- package/node_modules/@lua/shared-source-sync/dist/index.d.mts +424 -0
- package/node_modules/@lua/shared-source-sync/dist/index.d.ts +424 -0
- package/node_modules/@lua/shared-source-sync/dist/index.js +536 -0
- package/node_modules/@lua/shared-source-sync/dist/index.mjs +491 -0
- package/node_modules/@lua/shared-source-sync/package.json +26 -0
- package/node_modules/@lua/shared-source-sync/src/archive.ts +98 -0
- package/node_modules/@lua/shared-source-sync/src/filter.ts +72 -0
- package/node_modules/@lua/shared-source-sync/src/hash.ts +40 -0
- package/node_modules/@lua/shared-source-sync/src/http.ts +132 -0
- package/node_modules/@lua/shared-source-sync/src/index.ts +56 -0
- package/node_modules/@lua/shared-source-sync/src/pull.ts +50 -0
- package/node_modules/@lua/shared-source-sync/src/push.ts +94 -0
- package/node_modules/@lua/shared-source-sync/src/restore.ts +83 -0
- package/node_modules/@lua/shared-source-sync/src/skill-source.ts +55 -0
- package/node_modules/@lua/shared-source-sync/src/transfer.ts +92 -0
- package/node_modules/@lua/shared-source-sync/src/types.ts +88 -0
- package/node_modules/@lua/shared-source-sync/src/walk.ts +91 -0
- package/node_modules/@lua/shared-source-sync/tests/archive.test.ts +108 -0
- package/node_modules/@lua/shared-source-sync/tests/hash.test.ts +40 -0
- package/node_modules/@lua/shared-source-sync/tests/http.test.ts +77 -0
- package/node_modules/@lua/shared-source-sync/tests/push.test.ts +162 -0
- package/node_modules/@lua/shared-source-sync/tests/walk.test.ts +84 -0
- package/node_modules/@lua/shared-source-sync/tsconfig.json +12 -0
- package/node_modules/@lua/shared-source-sync/tsup.config.ts +9 -0
- package/node_modules/@lua/shared-source-sync/vitest.config.ts +8 -0
- package/node_modules/@lua/shared-types/dist/index.d.mts +27 -0
- package/node_modules/@lua/shared-types/dist/index.d.ts +27 -0
- package/node_modules/@lua/shared-types/src/agent-config.types.ts +14 -0
- package/node_modules/@lua/shared-types/src/chat-stream.types.ts +7 -0
- package/node_modules/@lua/shared-types/src/model-registry.ts +6 -0
- package/package.json +3 -1
- package/template/package.json +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the agent project backup feature.
|
|
3
|
+
*
|
|
4
|
+
* These mirror `packages/lua-cli/src/interfaces/backup.ts` byte-for-byte so the
|
|
5
|
+
* CLI and any server-side caller speak the same protocol against the existing
|
|
6
|
+
* `/developer/agents/:agentId/backup/*` endpoints on lua-api.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A single file entry in the backup manifest. Carries no content — content is
|
|
11
|
+
* stored as a blob in S3 under `backups/blobs/<hash>` and dereferenced at
|
|
12
|
+
* restore time.
|
|
13
|
+
*/
|
|
14
|
+
export interface BackupFileRef {
|
|
15
|
+
/**
|
|
16
|
+
* Path used both as identifier and as on-disk restore location.
|
|
17
|
+
* - In-project: relative to the agent's project root (e.g. `src/index.ts`).
|
|
18
|
+
* - External (monorepo cross-root): relative to the workspace root; restored
|
|
19
|
+
* under `.lua/external/<relativePath>` (see BAC-69).
|
|
20
|
+
*/
|
|
21
|
+
relativePath: string;
|
|
22
|
+
/** Truncated SHA-256 (16 hex chars) of the file's raw bytes. Used as the S3 key. */
|
|
23
|
+
hash: string;
|
|
24
|
+
/** Original (uncompressed) byte size. */
|
|
25
|
+
size: number;
|
|
26
|
+
/** Coarse classification for telemetry and UI grouping. */
|
|
27
|
+
type: 'source' | 'config' | 'other';
|
|
28
|
+
/** Defaults to in-project when absent. Cross-root files set this `true`. */
|
|
29
|
+
external?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Body of `POST /developer/agents/:agentId/backup/check-blobs`. */
|
|
33
|
+
export interface CheckBlobsRequest {
|
|
34
|
+
hashes: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Response shape from `check-blobs`. */
|
|
38
|
+
export interface CheckBlobsResponse {
|
|
39
|
+
missing: string[];
|
|
40
|
+
existing: string[];
|
|
41
|
+
results: Record<string, boolean>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Body of `POST /developer/agents/:agentId/backup/blob-upload-urls` and `.../blob-urls`. */
|
|
45
|
+
export interface GetBlobUrlsRequest {
|
|
46
|
+
hashes: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Response shape: `{ urls: { <hash>: <presignedUrl> }, expiresIn: <seconds> }`. */
|
|
50
|
+
export interface GetBlobUrlsResponse {
|
|
51
|
+
urls: Record<string, string>;
|
|
52
|
+
expiresIn: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Body of `POST /developer/agents/:agentId/backup/manifest`. */
|
|
56
|
+
export interface SaveManifestRequest {
|
|
57
|
+
projectHash: string;
|
|
58
|
+
files: BackupFileRef[];
|
|
59
|
+
version?: string;
|
|
60
|
+
createdBy?: string;
|
|
61
|
+
orgId?: string;
|
|
62
|
+
triggeredBy?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Metadata returned by `GET /developer/agents/:agentId/backup`. */
|
|
66
|
+
export interface BackupMetadata {
|
|
67
|
+
agentId: string;
|
|
68
|
+
orgId: string;
|
|
69
|
+
projectHash: string;
|
|
70
|
+
fileCount: number;
|
|
71
|
+
totalSize: number;
|
|
72
|
+
version?: string;
|
|
73
|
+
createdBy: string;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
/** Monotonic manifest version assigned by the V2 server path. */
|
|
76
|
+
activeVersion?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Full response from `GET /developer/agents/:agentId/backup/manifest`. */
|
|
80
|
+
export interface BackupManifest extends BackupMetadata {
|
|
81
|
+
files: BackupFileRef[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Response from `GET /developer/agents/:agentId/backup/check/:hash`. */
|
|
85
|
+
export interface BackupExistsResponse {
|
|
86
|
+
exists: boolean;
|
|
87
|
+
currentHash?: string;
|
|
88
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk an agent workspace from disk and produce a fresh manifest. Used by
|
|
3
|
+
* both the CLI (via `buildManifestFromDisk`) and server-side callers
|
|
4
|
+
* (`SourceSyncService`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readdirSync, readFileSync, statSync, type Dirent } from 'fs';
|
|
8
|
+
import { join, sep } from 'path';
|
|
9
|
+
import { hashContentTruncated, combineFileHashes } from './hash';
|
|
10
|
+
import { classifyFile, shouldSkipDirectory, shouldSkipFile, DEFAULT_MAX_FILE_BYTES } from './filter';
|
|
11
|
+
import type { BackupFileRef } from './types';
|
|
12
|
+
|
|
13
|
+
export interface WalkOptions {
|
|
14
|
+
/** Per-file size cap. Files larger than this are skipped (default 256KB). */
|
|
15
|
+
maxFileBytes?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WalkResult {
|
|
19
|
+
/** Sorted file references suitable for a backup manifest. */
|
|
20
|
+
files: BackupFileRef[];
|
|
21
|
+
/** Deterministic combined hash over the file set. */
|
|
22
|
+
projectHash: string;
|
|
23
|
+
/** Sum of original file sizes. */
|
|
24
|
+
totalSize: number;
|
|
25
|
+
/**
|
|
26
|
+
* Raw file content keyed by truncated hash. Caller uses this for the upload
|
|
27
|
+
* step instead of re-reading from disk. First-write-wins on collision so
|
|
28
|
+
* dedup is honored.
|
|
29
|
+
*/
|
|
30
|
+
contentByHash: Map<string, Buffer>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Walk `rootDir` and return a stable workspace snapshot. 16-char truncated
|
|
35
|
+
* SHA-256 hashes; path separators normalized to `/` for OS-independent
|
|
36
|
+
* manifest round-trip.
|
|
37
|
+
*/
|
|
38
|
+
export function walkWorkspace(rootDir: string, opts: WalkOptions = {}): WalkResult {
|
|
39
|
+
const maxBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
40
|
+
const refs: BackupFileRef[] = [];
|
|
41
|
+
const contentByHash = new Map<string, Buffer>();
|
|
42
|
+
let totalSize = 0;
|
|
43
|
+
|
|
44
|
+
const visit = (relPrefix: string): void => {
|
|
45
|
+
const absDir = relPrefix ? join(rootDir, relPrefix) : rootDir;
|
|
46
|
+
let entries: Dirent[];
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
// Curated skip list + file-skip rules — catches dotted dirs, __tests__, etc.
|
|
55
|
+
if (shouldSkipDirectory(entry.name)) continue;
|
|
56
|
+
if (shouldSkipFile(entry.name)) continue;
|
|
57
|
+
visit(relPrefix ? `${relPrefix}${sep}${entry.name}` : entry.name);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!entry.isFile()) continue;
|
|
61
|
+
if (shouldSkipFile(entry.name)) continue;
|
|
62
|
+
|
|
63
|
+
const rel = (relPrefix ? `${relPrefix}${sep}${entry.name}` : entry.name).split(sep).join('/');
|
|
64
|
+
const abs = join(rootDir, rel);
|
|
65
|
+
let stats;
|
|
66
|
+
try {
|
|
67
|
+
stats = statSync(abs);
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (stats.size > maxBytes) continue;
|
|
72
|
+
let content: Buffer;
|
|
73
|
+
try {
|
|
74
|
+
content = readFileSync(abs);
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Hash the UTF-8 string form — must match the compiler's `hashContent`
|
|
79
|
+
// byte-for-byte so the CLI and server walks produce identical hashes.
|
|
80
|
+
const hash = hashContentTruncated(content.toString('utf-8'));
|
|
81
|
+
refs.push({ relativePath: rel, hash, size: stats.size, type: classifyFile(rel) });
|
|
82
|
+
if (!contentByHash.has(hash)) contentByHash.set(hash, content);
|
|
83
|
+
totalSize += stats.size;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
visit('');
|
|
88
|
+
refs.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
89
|
+
const projectHash = combineFileHashes(refs);
|
|
90
|
+
return { files: refs, projectHash, totalSize, contentByHash };
|
|
91
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { gzipSync } from 'zlib';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import {
|
|
7
|
+
ARCHIVE_SCHEMA_VERSION,
|
|
8
|
+
decodeWorkspaceArchive,
|
|
9
|
+
encodeWorkspaceArchive,
|
|
10
|
+
writeArchiveToWorkspace,
|
|
11
|
+
} from '../src/archive';
|
|
12
|
+
|
|
13
|
+
describe('canonical-source archive (shared-source-sync)', () => {
|
|
14
|
+
let workspace: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
workspace = mkdtempSync(join(tmpdir(), 'shared-archive-'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (existsSync(workspace)) rmSync(workspace, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('exports a stable schema version of 1', () => {
|
|
25
|
+
expect(ARCHIVE_SCHEMA_VERSION).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns null for a non-existent or empty workspace', () => {
|
|
29
|
+
expect(encodeWorkspaceArchive(join(workspace, 'no-such'))).toBeNull();
|
|
30
|
+
expect(encodeWorkspaceArchive(workspace)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('round-trips a small TS workspace', () => {
|
|
34
|
+
mkdirSync(join(workspace, 'src'));
|
|
35
|
+
writeFileSync(join(workspace, 'lua.skill.yaml'), 'agent:\n agentId: a\n');
|
|
36
|
+
writeFileSync(join(workspace, 'src/tool.ts'), 'export const tool = 1;\n');
|
|
37
|
+
|
|
38
|
+
const archive = encodeWorkspaceArchive(workspace);
|
|
39
|
+
expect(archive).toBeTruthy();
|
|
40
|
+
const decoded = decodeWorkspaceArchive(archive!);
|
|
41
|
+
expect(Object.keys(decoded).sort()).toEqual(['lua.skill.yaml', 'src/tool.ts']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('excludes node_modules, .git, dist, .env, lockfiles', () => {
|
|
45
|
+
mkdirSync(join(workspace, 'src'));
|
|
46
|
+
mkdirSync(join(workspace, 'node_modules'));
|
|
47
|
+
mkdirSync(join(workspace, '.git'));
|
|
48
|
+
mkdirSync(join(workspace, 'dist'));
|
|
49
|
+
writeFileSync(join(workspace, 'src/keep.ts'), 'x');
|
|
50
|
+
writeFileSync(join(workspace, 'node_modules/drop.js'), 'x');
|
|
51
|
+
writeFileSync(join(workspace, '.git/HEAD'), 'x');
|
|
52
|
+
writeFileSync(join(workspace, 'dist/bundle.js'), 'x');
|
|
53
|
+
writeFileSync(join(workspace, '.env'), 'SECRET=1');
|
|
54
|
+
writeFileSync(join(workspace, 'pnpm-lock.yaml'), '');
|
|
55
|
+
|
|
56
|
+
const decoded = decodeWorkspaceArchive(encodeWorkspaceArchive(workspace)!);
|
|
57
|
+
expect(Object.keys(decoded)).toEqual(['src/keep.ts']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Regression for Cursor Bugbot PR #402: when the admin-tool-builder
|
|
61
|
+
// archive's `EXCLUDE_DIRS` was consolidated into shared `SKIP_DIRECTORIES`,
|
|
62
|
+
// `build` was initially dropped from the list. Per-skill archives then
|
|
63
|
+
// started sweeping in `build/` (Vite, Next.js, custom) outputs that don't
|
|
64
|
+
// belong in the canonical source store. Pins the restored exclusion.
|
|
65
|
+
it('excludes build/, .turbo/, .next/, coverage/, dist-v2/ (build-output dirs)', () => {
|
|
66
|
+
mkdirSync(join(workspace, 'src'));
|
|
67
|
+
writeFileSync(join(workspace, 'src/keep.ts'), 'x');
|
|
68
|
+
for (const dir of ['build', '.turbo', '.next', 'coverage', 'dist-v2']) {
|
|
69
|
+
mkdirSync(join(workspace, dir));
|
|
70
|
+
writeFileSync(join(workspace, `${dir}/artifact.js`), 'compiled');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const decoded = decodeWorkspaceArchive(encodeWorkspaceArchive(workspace)!);
|
|
74
|
+
expect(Object.keys(decoded)).toEqual(['src/keep.ts']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('skips files over 256KB', () => {
|
|
78
|
+
writeFileSync(join(workspace, 'big.ts'), 'x'.repeat(257 * 1024));
|
|
79
|
+
writeFileSync(join(workspace, 'small.ts'), 'x');
|
|
80
|
+
const decoded = decodeWorkspaceArchive(encodeWorkspaceArchive(workspace)!);
|
|
81
|
+
expect(Object.keys(decoded)).toEqual(['small.ts']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('writeArchiveToWorkspace rejects path-traversal entries', () => {
|
|
85
|
+
expect(() => writeArchiveToWorkspace(workspace, { '../escape.ts': 'evil' })).toThrow(/Refusing to write/);
|
|
86
|
+
expect(() => writeArchiveToWorkspace(workspace, { 'src/../../escape.ts': 'evil' })).toThrow(/Refusing to write/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('decodeWorkspaceArchive throws on non-object payloads', () => {
|
|
90
|
+
const bogus = gzipSync(Buffer.from('[1,2,3]', 'utf8')).toString('base64');
|
|
91
|
+
expect(() => decodeWorkspaceArchive(bogus)).toThrow(/JSON object map/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('preserves unicode content byte-for-byte through the round-trip', () => {
|
|
95
|
+
mkdirSync(join(workspace, 'src'));
|
|
96
|
+
const tricky = "// 漢字 🎯\nexport const s = '\\n';\n";
|
|
97
|
+
writeFileSync(join(workspace, 'src/tricky.ts'), tricky, 'utf8');
|
|
98
|
+
const decoded = decodeWorkspaceArchive(encodeWorkspaceArchive(workspace)!);
|
|
99
|
+
|
|
100
|
+
const restore = mkdtempSync(join(tmpdir(), 'shared-archive-restore-'));
|
|
101
|
+
try {
|
|
102
|
+
writeArchiveToWorkspace(restore, decoded);
|
|
103
|
+
expect(readFileSync(join(restore, 'src/tricky.ts'), 'utf8')).toBe(tricky);
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(restore, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { FILE_HASH_LENGTH, combineFileHashes, hashContentTruncated, sha256Hex } from '../src/hash';
|
|
4
|
+
|
|
5
|
+
describe('hashContentTruncated', () => {
|
|
6
|
+
it('produces 16-char hex truncated SHA-256 (wire compatibility)', () => {
|
|
7
|
+
const content = 'hello world';
|
|
8
|
+
const expected = createHash('sha256').update(content).digest('hex').slice(0, FILE_HASH_LENGTH);
|
|
9
|
+
expect(hashContentTruncated(content)).toBe(expected);
|
|
10
|
+
expect(hashContentTruncated(content)).toHaveLength(16);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('matches across string and equivalent Buffer input', () => {
|
|
14
|
+
const content = 'export const x = 1;';
|
|
15
|
+
expect(hashContentTruncated(content)).toBe(hashContentTruncated(Buffer.from(content, 'utf-8')));
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('combineFileHashes', () => {
|
|
20
|
+
it('is order-independent (sorts by relativePath before combining)', () => {
|
|
21
|
+
const a = [
|
|
22
|
+
{ relativePath: 'src/a.ts', hash: '0000000000000001' },
|
|
23
|
+
{ relativePath: 'src/b.ts', hash: '0000000000000002' },
|
|
24
|
+
];
|
|
25
|
+
const b = [
|
|
26
|
+
{ relativePath: 'src/b.ts', hash: '0000000000000002' },
|
|
27
|
+
{ relativePath: 'src/a.ts', hash: '0000000000000001' },
|
|
28
|
+
];
|
|
29
|
+
expect(combineFileHashes(a)).toBe(combineFileHashes(b));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('produces deterministic full-length SHA-256 over the pipe-joined sorted hashes', () => {
|
|
33
|
+
const files = [
|
|
34
|
+
{ relativePath: 'src/index.ts', hash: 'abc' },
|
|
35
|
+
{ relativePath: 'src/util.ts', hash: 'def' },
|
|
36
|
+
];
|
|
37
|
+
const expected = sha256Hex('abc|def');
|
|
38
|
+
expect(combineFileHashes(files)).toBe(expected);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { BackupHttpClient, BackupHttpError } from '../src/http';
|
|
3
|
+
|
|
4
|
+
function makeFetchStub(response: { status: number; body?: unknown; statusText?: string }) {
|
|
5
|
+
return vi.fn(async (_url: any, init?: any) => {
|
|
6
|
+
return {
|
|
7
|
+
ok: response.status >= 200 && response.status < 300,
|
|
8
|
+
status: response.status,
|
|
9
|
+
statusText: response.statusText ?? 'OK',
|
|
10
|
+
text: async () => (response.body === undefined ? '' : JSON.stringify(response.body)),
|
|
11
|
+
} as any;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('BackupHttpClient', () => {
|
|
16
|
+
it('builds the developer agent backup URL with the agent id encoded', async () => {
|
|
17
|
+
const fetchStub = makeFetchStub({ status: 200, body: { missing: [], existing: [], results: {} } });
|
|
18
|
+
const client = new BackupHttpClient({
|
|
19
|
+
baseUrl: 'https://api.example.com/',
|
|
20
|
+
authHeader: 'Bearer abc',
|
|
21
|
+
agentId: 'agent 1',
|
|
22
|
+
fetch: fetchStub as any,
|
|
23
|
+
});
|
|
24
|
+
await client.checkBlobsExist(['h1']);
|
|
25
|
+
expect(fetchStub).toHaveBeenCalledTimes(1);
|
|
26
|
+
const [url, init] = fetchStub.mock.calls[0];
|
|
27
|
+
expect(url).toBe('https://api.example.com/developer/agents/agent%201/backup/check-blobs');
|
|
28
|
+
expect(init?.method).toBe('POST');
|
|
29
|
+
expect(init?.headers).toMatchObject({
|
|
30
|
+
Authorization: 'Bearer abc',
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
});
|
|
33
|
+
expect(init?.body).toBe(JSON.stringify({ hashes: ['h1'] }));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws BackupHttpError on non-2xx with status preserved', async () => {
|
|
37
|
+
const fetchStub = makeFetchStub({ status: 403, statusText: 'Forbidden', body: { message: 'nope' } });
|
|
38
|
+
const client = new BackupHttpClient({
|
|
39
|
+
baseUrl: 'https://api.example.com',
|
|
40
|
+
authHeader: 'Bearer x',
|
|
41
|
+
agentId: 'a',
|
|
42
|
+
fetch: fetchStub as any,
|
|
43
|
+
});
|
|
44
|
+
await expect(client.getManifest()).rejects.toBeInstanceOf(BackupHttpError);
|
|
45
|
+
try {
|
|
46
|
+
await client.getManifest();
|
|
47
|
+
throw new Error('unreachable');
|
|
48
|
+
} catch (err) {
|
|
49
|
+
expect(err).toBeInstanceOf(BackupHttpError);
|
|
50
|
+
expect((err as BackupHttpError).status).toBe(403);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns parsed JSON for saveManifest', async () => {
|
|
55
|
+
const fetchStub = makeFetchStub({
|
|
56
|
+
status: 200,
|
|
57
|
+
body: {
|
|
58
|
+
agentId: 'a',
|
|
59
|
+
orgId: 'o',
|
|
60
|
+
projectHash: 'p',
|
|
61
|
+
fileCount: 1,
|
|
62
|
+
totalSize: 10,
|
|
63
|
+
createdBy: 'cli',
|
|
64
|
+
createdAt: '2026-05-12T00:00:00Z',
|
|
65
|
+
activeVersion: 7,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const client = new BackupHttpClient({
|
|
69
|
+
baseUrl: 'https://api.example.com',
|
|
70
|
+
authHeader: 'Bearer x',
|
|
71
|
+
agentId: 'a',
|
|
72
|
+
fetch: fetchStub as any,
|
|
73
|
+
});
|
|
74
|
+
const meta = await client.saveManifest({ projectHash: 'p', files: [] });
|
|
75
|
+
expect(meta.activeVersion).toBe(7);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { gunzipSync } from 'zlib';
|
|
6
|
+
import { pushAgentBackup } from '../src/push';
|
|
7
|
+
|
|
8
|
+
type Fetch = ReturnType<typeof vi.fn>;
|
|
9
|
+
|
|
10
|
+
function makeFakeServerAndS3() {
|
|
11
|
+
// Track S3 PUT bodies keyed by URL (we use the URL as the "blob hash" surrogate).
|
|
12
|
+
const s3 = new Map<string, Buffer>();
|
|
13
|
+
let savedManifest: any = null;
|
|
14
|
+
|
|
15
|
+
const fetchStub: Fetch = vi.fn(async (url: string, init?: any) => {
|
|
16
|
+
if (url.includes('/backup/check-blobs')) {
|
|
17
|
+
const { hashes } = JSON.parse(init.body);
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 200,
|
|
21
|
+
statusText: 'OK',
|
|
22
|
+
text: async () =>
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
missing: hashes, // pretend nothing exists yet
|
|
25
|
+
existing: [],
|
|
26
|
+
results: Object.fromEntries(hashes.map((h: string) => [h, false])),
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (url.includes('/backup/blob-upload-urls')) {
|
|
31
|
+
const { hashes } = JSON.parse(init.body);
|
|
32
|
+
const urls: Record<string, string> = {};
|
|
33
|
+
for (const h of hashes) urls[h] = `https://s3.example/${h}`;
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
status: 200,
|
|
37
|
+
statusText: 'OK',
|
|
38
|
+
text: async () => JSON.stringify({ urls, expiresIn: 3600 }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (url.startsWith('https://s3.example/')) {
|
|
42
|
+
// S3 PUT
|
|
43
|
+
const hash = url.replace('https://s3.example/', '');
|
|
44
|
+
s3.set(hash, Buffer.from(init.body));
|
|
45
|
+
return { ok: true, status: 200, statusText: 'OK', text: async () => '' };
|
|
46
|
+
}
|
|
47
|
+
if (url.includes('/backup/manifest')) {
|
|
48
|
+
savedManifest = JSON.parse(init.body);
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
status: 200,
|
|
52
|
+
statusText: 'OK',
|
|
53
|
+
text: async () =>
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
agentId: 'a',
|
|
56
|
+
orgId: 'o',
|
|
57
|
+
projectHash: savedManifest.projectHash,
|
|
58
|
+
fileCount: savedManifest.files.length,
|
|
59
|
+
totalSize: 0,
|
|
60
|
+
createdBy: savedManifest.createdBy,
|
|
61
|
+
createdAt: '2026-05-12T00:00:00Z',
|
|
62
|
+
activeVersion: 1,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`unexpected URL: ${url}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { fetchStub, s3, getSavedManifest: () => savedManifest };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('pushAgentBackup', () => {
|
|
73
|
+
let dir: string;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
dir = mkdtempSync(join(tmpdir(), 'shared-source-sync-push-'));
|
|
77
|
+
});
|
|
78
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
79
|
+
|
|
80
|
+
it('walks workspace, uploads gzipped blobs, saves manifest', async () => {
|
|
81
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
82
|
+
writeFileSync(join(dir, 'src/index.ts'), 'export const x = 1;');
|
|
83
|
+
writeFileSync(join(dir, 'lua.skill.yaml'), 'agent: {agentId: a}');
|
|
84
|
+
|
|
85
|
+
const { fetchStub, s3, getSavedManifest } = makeFakeServerAndS3();
|
|
86
|
+
const result = await pushAgentBackup({
|
|
87
|
+
http: {
|
|
88
|
+
baseUrl: 'https://api.example.com',
|
|
89
|
+
authHeader: 'Bearer test',
|
|
90
|
+
agentId: 'a',
|
|
91
|
+
fetch: fetchStub as any,
|
|
92
|
+
},
|
|
93
|
+
workspaceDir: dir,
|
|
94
|
+
orgId: 'o',
|
|
95
|
+
triggeredBy: 'unit-test',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result.fileCount).toBe(2);
|
|
99
|
+
expect(result.filesUploaded).toBe(2);
|
|
100
|
+
expect(result.activeVersion).toBe(1);
|
|
101
|
+
|
|
102
|
+
// Manifest was saved with both files
|
|
103
|
+
const manifest = getSavedManifest();
|
|
104
|
+
const paths = manifest.files.map((f: any) => f.relativePath).sort();
|
|
105
|
+
expect(paths).toEqual(['lua.skill.yaml', 'src/index.ts']);
|
|
106
|
+
expect(manifest.orgId).toBe('o');
|
|
107
|
+
expect(manifest.triggeredBy).toBe('unit-test');
|
|
108
|
+
expect(manifest.createdBy).toBe('cli');
|
|
109
|
+
|
|
110
|
+
// S3 received gzipped content
|
|
111
|
+
expect(s3.size).toBe(2);
|
|
112
|
+
for (const buf of s3.values()) {
|
|
113
|
+
// Magic bytes for gzip
|
|
114
|
+
expect(buf[0]).toBe(0x1f);
|
|
115
|
+
expect(buf[1]).toBe(0x8b);
|
|
116
|
+
// Decompress to verify it's the original
|
|
117
|
+
const decoded = gunzipSync(buf).toString('utf-8');
|
|
118
|
+
expect(['export const x = 1;', 'agent: {agentId: a}']).toContain(decoded);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('skips upload when server reports all blobs already exist', async () => {
|
|
123
|
+
writeFileSync(join(dir, 'a.ts'), 'x');
|
|
124
|
+
const fetchStub: Fetch = vi.fn(async (url: string, init?: any) => {
|
|
125
|
+
if (url.includes('/check-blobs')) {
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
status: 200,
|
|
129
|
+
statusText: 'OK',
|
|
130
|
+
text: async () => JSON.stringify({ missing: [], existing: ['h'], results: {} }),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (url.includes('/manifest')) {
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
status: 200,
|
|
137
|
+
statusText: 'OK',
|
|
138
|
+
text: async () =>
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
agentId: 'a',
|
|
141
|
+
orgId: 'o',
|
|
142
|
+
projectHash: 'p',
|
|
143
|
+
fileCount: 1,
|
|
144
|
+
totalSize: 1,
|
|
145
|
+
createdBy: 'cli',
|
|
146
|
+
createdAt: 'now',
|
|
147
|
+
activeVersion: 2,
|
|
148
|
+
}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`unexpected URL ${url}`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await pushAgentBackup({
|
|
155
|
+
http: { baseUrl: 'https://api.example.com', authHeader: 'Bearer x', agentId: 'a', fetch: fetchStub as any },
|
|
156
|
+
workspaceDir: dir,
|
|
157
|
+
orgId: 'o',
|
|
158
|
+
});
|
|
159
|
+
expect(result.filesUploaded).toBe(0);
|
|
160
|
+
expect(result.activeVersion).toBe(2);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { walkWorkspace } from '../src/walk';
|
|
6
|
+
|
|
7
|
+
describe('walkWorkspace', () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dir = mkdtempSync(join(tmpdir(), 'shared-source-sync-walk-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('includes source files and excludes node_modules / dist / dotfiles / tests', () => {
|
|
19
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
20
|
+
mkdirSync(join(dir, 'node_modules'), { recursive: true });
|
|
21
|
+
mkdirSync(join(dir, 'dist-v2'), { recursive: true });
|
|
22
|
+
mkdirSync(join(dir, '__tests__'), { recursive: true });
|
|
23
|
+
writeFileSync(join(dir, 'src/index.ts'), 'export const a = 1;');
|
|
24
|
+
writeFileSync(join(dir, 'src/util.ts'), 'export const b = 2;');
|
|
25
|
+
writeFileSync(join(dir, 'lua.skill.yaml'), 'agent:\n agentId: foo');
|
|
26
|
+
writeFileSync(join(dir, 'node_modules/x.ts'), 'dropme');
|
|
27
|
+
writeFileSync(join(dir, 'dist-v2/out.js'), 'dropme');
|
|
28
|
+
writeFileSync(join(dir, '__tests__/x.test.ts'), 'dropme');
|
|
29
|
+
writeFileSync(join(dir, '.eslintrc'), 'dropme');
|
|
30
|
+
writeFileSync(join(dir, 'package-lock.json'), 'dropme');
|
|
31
|
+
|
|
32
|
+
const result = walkWorkspace(dir);
|
|
33
|
+
const paths = result.files.map((f) => f.relativePath).sort();
|
|
34
|
+
expect(paths).toEqual(['lua.skill.yaml', 'src/index.ts', 'src/util.ts']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('dedupes identical content by hash (contentByHash has one entry per unique hash)', () => {
|
|
38
|
+
writeFileSync(join(dir, 'a.ts'), 'same content');
|
|
39
|
+
writeFileSync(join(dir, 'b.ts'), 'same content');
|
|
40
|
+
const result = walkWorkspace(dir);
|
|
41
|
+
expect(result.files).toHaveLength(2);
|
|
42
|
+
expect(result.contentByHash.size).toBe(1);
|
|
43
|
+
expect(result.files[0].hash).toBe(result.files[1].hash);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('normalizes path separators to forward slashes', () => {
|
|
47
|
+
mkdirSync(join(dir, 'src', 'sub'), { recursive: true });
|
|
48
|
+
writeFileSync(join(dir, 'src/sub/x.ts'), 'x');
|
|
49
|
+
const result = walkWorkspace(dir);
|
|
50
|
+
expect(result.files[0].relativePath).toBe('src/sub/x.ts');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('produces stable projectHash for identical input', () => {
|
|
54
|
+
writeFileSync(join(dir, 'a.ts'), 'a');
|
|
55
|
+
writeFileSync(join(dir, 'b.ts'), 'b');
|
|
56
|
+
const r1 = walkWorkspace(dir);
|
|
57
|
+
const r2 = walkWorkspace(dir);
|
|
58
|
+
expect(r1.projectHash).toBe(r2.projectHash);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('respects maxFileBytes', () => {
|
|
62
|
+
writeFileSync(join(dir, 'big.ts'), 'x'.repeat(2048));
|
|
63
|
+
writeFileSync(join(dir, 'small.ts'), 'x');
|
|
64
|
+
const result = walkWorkspace(dir, { maxFileBytes: 1024 });
|
|
65
|
+
expect(result.files.map((f) => f.relativePath)).toEqual(['small.ts']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Regression pin: PR #402 round-3 Bugbot review caught that adding `build`
|
|
69
|
+
// to the shared SKIP_DIRECTORIES set silently broke the CLI compiler's
|
|
70
|
+
// primitive scan and the per-agent backup walker. The archive encoder
|
|
71
|
+
// owns its own ARCHIVE_ONLY_SKIP_DIRECTORIES set for that purpose;
|
|
72
|
+
// `walkWorkspace` (consumed by `buildManifestFromDisk` for `lua push
|
|
73
|
+
// backup --fresh` AND re-exported as `shouldSkipDirectory` to the CLI
|
|
74
|
+
// compiler) MUST keep `build/` source visible. Developers with custom
|
|
75
|
+
// Vite/Next/etc. layouts depend on it.
|
|
76
|
+
it('does NOT skip `build/` — only the archive encoder excludes it, not the walker', () => {
|
|
77
|
+
mkdirSync(join(dir, 'build'), { recursive: true });
|
|
78
|
+
writeFileSync(join(dir, 'build/keep.ts'), 'export const x = 1;');
|
|
79
|
+
writeFileSync(join(dir, 'src.ts'), 'export const y = 2;');
|
|
80
|
+
const result = walkWorkspace(dir);
|
|
81
|
+
const paths = result.files.map((f) => f.relativePath).sort();
|
|
82
|
+
expect(paths).toEqual(['build/keep.ts', 'src.ts']);
|
|
83
|
+
});
|
|
84
|
+
});
|