archiver-ts 0.0.1
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/.draft/vibing.md +7 -0
- package/.prettierrc +5 -0
- package/.scripts/build.js +7 -0
- package/.scripts/e2e.js +3 -0
- package/.scripts/lines.js +53 -0
- package/.scripts/publish.js +5 -0
- package/.scripts/recover.js +154 -0
- package/README.dev.md +124 -0
- package/README.md +82 -0
- package/dist/index.js +2687 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
- package/rollup.config.mjs +46 -0
- package/src/commands/archive.ts +101 -0
- package/src/commands/cd-shell.ts +24 -0
- package/src/commands/check.ts +40 -0
- package/src/commands/command-utils.ts +50 -0
- package/src/commands/config.ts +119 -0
- package/src/commands/index.ts +26 -0
- package/src/commands/list-interactive.ts +201 -0
- package/src/commands/list.ts +118 -0
- package/src/commands/log.ts +102 -0
- package/src/commands/update.ts +78 -0
- package/src/commands/vault.ts +212 -0
- package/src/consts/defaults.ts +32 -0
- package/src/consts/enums.ts +15 -0
- package/src/consts/index.ts +7 -0
- package/src/consts/path-tree.ts +24 -0
- package/src/consts/update.ts +13 -0
- package/src/core/context.ts +281 -0
- package/src/core/initialize.ts +295 -0
- package/src/global.d.ts +76 -0
- package/src/index.ts +30 -0
- package/src/services/archive.ts +579 -0
- package/src/services/audit-logger.ts +33 -0
- package/src/services/check.ts +345 -0
- package/src/services/config.ts +71 -0
- package/src/services/context.ts +46 -0
- package/src/services/log.ts +83 -0
- package/src/services/update.ts +121 -0
- package/src/services/vault.ts +247 -0
- package/src/utils/date.ts +39 -0
- package/src/utils/fs.ts +61 -0
- package/src/utils/json.ts +70 -0
- package/src/utils/parse.ts +63 -0
- package/src/utils/prompt.ts +20 -0
- package/src/utils/terminal.ts +168 -0
- package/tests/commands/cd-marker.test.ts +13 -0
- package/tests/commands/list-interactive.test.ts +26 -0
- package/tests/core/initialize.test.ts +130 -0
- package/tests/e2e/cli.e2e.test.ts +104 -0
- package/tests/e2e/mock-cli.ts +42 -0
- package/tests/services/archive-workflow.test.ts +176 -0
- package/tests/utils/parse.test.ts +44 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArchiveStatus } from '../../src/consts/index.js';
|
|
3
|
+
import { isActionAvailable, type InteractiveListEntry } from '../../src/commands/list-interactive.js';
|
|
4
|
+
|
|
5
|
+
function makeEntry(status: ArchiveStatus): InteractiveListEntry {
|
|
6
|
+
return {
|
|
7
|
+
id: 1,
|
|
8
|
+
status,
|
|
9
|
+
title: '@(0)/demo.txt',
|
|
10
|
+
path: '/tmp/demo.txt',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('list interactive', () => {
|
|
15
|
+
it('allows enter/restore actions for archived entries', () => {
|
|
16
|
+
const entry = makeEntry(ArchiveStatus.Archived);
|
|
17
|
+
expect(isActionAvailable(entry, 'enter')).toBe(true);
|
|
18
|
+
expect(isActionAvailable(entry, 'restore')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('disables enter/restore actions for restored entries', () => {
|
|
22
|
+
const entry = makeEntry(ArchiveStatus.Restored);
|
|
23
|
+
expect(isActionAvailable(entry, 'enter')).toBe(false);
|
|
24
|
+
expect(isActionAvailable(entry, 'restore')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { ensureArvShellWrapper } from '../../src/core/initialize.js';
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
async function makeHome(): Promise<string> {
|
|
10
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'arv-init-'));
|
|
11
|
+
tempDirs.push(dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
while (tempDirs.length > 0) {
|
|
17
|
+
const dir = tempDirs.pop();
|
|
18
|
+
if (dir) {
|
|
19
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('shell wrapper initialize', () => {
|
|
25
|
+
it('creates bash wrapper when missing', async () => {
|
|
26
|
+
const homeDir = await makeHome();
|
|
27
|
+
const result = await ensureArvShellWrapper({
|
|
28
|
+
homeDir,
|
|
29
|
+
shellPath: '/bin/bash',
|
|
30
|
+
stdinIsTTY: true,
|
|
31
|
+
env: {},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(result.installed).toBe(true);
|
|
35
|
+
expect(result.shell).toBe('bash');
|
|
36
|
+
expect(result.reloadCommand).toContain('source');
|
|
37
|
+
const content = await fs.readFile(path.join(homeDir, '.bashrc'), 'utf8');
|
|
38
|
+
expect(content).toContain('# >>> archiver arv wrapper >>>');
|
|
39
|
+
expect(content).toContain('arv() {');
|
|
40
|
+
expect(content).toContain('command arv "$@"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not duplicate managed wrapper block and skips second install', async () => {
|
|
44
|
+
const homeDir = await makeHome();
|
|
45
|
+
const first = await ensureArvShellWrapper({
|
|
46
|
+
homeDir,
|
|
47
|
+
shellPath: '/bin/zsh',
|
|
48
|
+
stdinIsTTY: true,
|
|
49
|
+
env: {},
|
|
50
|
+
});
|
|
51
|
+
const second = await ensureArvShellWrapper({
|
|
52
|
+
homeDir,
|
|
53
|
+
shellPath: '/bin/zsh',
|
|
54
|
+
stdinIsTTY: true,
|
|
55
|
+
env: {},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(first.installed).toBe(true);
|
|
59
|
+
expect(second.installed).toBe(false);
|
|
60
|
+
const content = await fs.readFile(path.join(homeDir, '.zshrc'), 'utf8');
|
|
61
|
+
const markerCount = (content.match(/# >>> archiver arv wrapper >>>/g) ?? []).length;
|
|
62
|
+
expect(markerCount).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps user-defined arv function untouched', async () => {
|
|
66
|
+
const homeDir = await makeHome();
|
|
67
|
+
const rcPath = path.join(homeDir, '.bashrc');
|
|
68
|
+
await fs.writeFile(
|
|
69
|
+
rcPath,
|
|
70
|
+
`
|
|
71
|
+
arv() {
|
|
72
|
+
echo "custom wrapper"
|
|
73
|
+
}
|
|
74
|
+
`,
|
|
75
|
+
'utf8',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const result = await ensureArvShellWrapper({
|
|
79
|
+
homeDir,
|
|
80
|
+
shellPath: '/bin/bash',
|
|
81
|
+
stdinIsTTY: true,
|
|
82
|
+
env: {},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.installed).toBe(false);
|
|
86
|
+
const content = await fs.readFile(rcPath, 'utf8');
|
|
87
|
+
expect(content).toContain('custom wrapper');
|
|
88
|
+
expect(content).not.toContain('# >>> archiver arv wrapper >>>');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('creates fish function file for fish shell', async () => {
|
|
92
|
+
const homeDir = await makeHome();
|
|
93
|
+
const result = await ensureArvShellWrapper({
|
|
94
|
+
homeDir,
|
|
95
|
+
shellPath: '/usr/bin/fish',
|
|
96
|
+
stdinIsTTY: true,
|
|
97
|
+
env: {},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result.installed).toBe(true);
|
|
101
|
+
expect(result.shell).toBe('fish');
|
|
102
|
+
const fishFunction = await fs.readFile(path.join(homeDir, '.config', 'fish', 'functions', 'arv.fish'), 'utf8');
|
|
103
|
+
expect(fishFunction).toContain('function arv');
|
|
104
|
+
expect(fishFunction).toContain('__ARCHIVER_CD__:');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('creates powershell profile wrapper when missing', async () => {
|
|
108
|
+
const homeDir = await makeHome();
|
|
109
|
+
const result = await ensureArvShellWrapper({
|
|
110
|
+
homeDir,
|
|
111
|
+
shellPath: '/usr/bin/pwsh',
|
|
112
|
+
stdinIsTTY: true,
|
|
113
|
+
env: {},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.installed).toBe(true);
|
|
117
|
+
expect(result.shell).toBe('powershell');
|
|
118
|
+
expect(result.profilePath).toContain('Microsoft.PowerShell_profile.ps1');
|
|
119
|
+
expect(result.reloadCommand).toContain('. ');
|
|
120
|
+
expect(result.profilePath).toBeDefined();
|
|
121
|
+
|
|
122
|
+
const profilePath = result.profilePath ?? '';
|
|
123
|
+
const absoluteProfilePath = profilePath.startsWith('~')
|
|
124
|
+
? path.join(homeDir, profilePath.slice(2))
|
|
125
|
+
: profilePath;
|
|
126
|
+
const profileContent = await fs.readFile(absoluteProfilePath, 'utf8');
|
|
127
|
+
expect(profileContent).toContain('function arv');
|
|
128
|
+
expect(profileContent).toContain('__ARCHIVER_CD__:');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { cleanDirs, mkTempDir, run } from './mock-cli.js';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
cleanDirs();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('cli e2e', () => {
|
|
11
|
+
it('uses project-local root when NODE_ENV is not production', () => {
|
|
12
|
+
const projectDir = mkTempDir('archiver-e2e-dev-');
|
|
13
|
+
const prodRoot = mkTempDir('archiver-e2e-dev-prod-root-');
|
|
14
|
+
const filePath = path.join(projectDir, 'dev-file.txt');
|
|
15
|
+
fs.writeFileSync(filePath, 'dev data\n', 'utf8');
|
|
16
|
+
|
|
17
|
+
const env = {
|
|
18
|
+
NODE_ENV: 'development',
|
|
19
|
+
ARCHIVER_PATH: prodRoot,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
run(['config', 'update-check', 'off'], { cwd: projectDir, env });
|
|
23
|
+
run(['put', filePath], { cwd: projectDir, env });
|
|
24
|
+
|
|
25
|
+
const rootDir = path.join(projectDir, '.archiver');
|
|
26
|
+
const archivedObjectPath = path.join(rootDir, 'vaults', '0', '1', 'dev-file.txt');
|
|
27
|
+
expect(fs.existsSync(archivedObjectPath)).toBe(true);
|
|
28
|
+
expect(fs.existsSync(path.join(prodRoot, 'vaults', '0', '1', 'dev-file.txt'))).toBe(false);
|
|
29
|
+
|
|
30
|
+
const cdOutput = run(['cd', '1', '--print'], { cwd: projectDir, env });
|
|
31
|
+
expect(cdOutput.trim()).toBe(path.join(rootDir, 'vaults', '0', '1'));
|
|
32
|
+
|
|
33
|
+
run(['restore', '1'], { cwd: projectDir, env });
|
|
34
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
35
|
+
expect(fs.existsSync(path.join(rootDir, 'vaults', '0', '1'))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('uses ARCHIVER_PATH as root in production runtime', () => {
|
|
39
|
+
const projectDir = mkTempDir('archiver-e2e-prod-');
|
|
40
|
+
const fakeHome = mkTempDir('archiver-e2e-home-');
|
|
41
|
+
const customRoot = mkTempDir('archiver-e2e-custom-root-');
|
|
42
|
+
const filePath = path.join(projectDir, 'prod-file.txt');
|
|
43
|
+
fs.writeFileSync(filePath, 'prod data\n', 'utf8');
|
|
44
|
+
|
|
45
|
+
const env = {
|
|
46
|
+
NODE_ENV: 'production',
|
|
47
|
+
ARCHIVER_PATH: customRoot,
|
|
48
|
+
HOME: fakeHome,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
run(['config', 'update-check', 'off'], { cwd: projectDir, env });
|
|
52
|
+
run(['put', filePath], { cwd: projectDir, env });
|
|
53
|
+
|
|
54
|
+
const archivedObjectPath = path.join(customRoot, 'vaults', '0', '1', 'prod-file.txt');
|
|
55
|
+
const devRoot = path.join(projectDir, '.archiver');
|
|
56
|
+
const homeRoot = path.join(fakeHome, '.archiver');
|
|
57
|
+
|
|
58
|
+
expect(fs.existsSync(archivedObjectPath)).toBe(true);
|
|
59
|
+
expect(fs.existsSync(devRoot)).toBe(false);
|
|
60
|
+
expect(fs.existsSync(homeRoot)).toBe(false);
|
|
61
|
+
|
|
62
|
+
const cdOutput = run(['cd', '1', '--print'], { cwd: projectDir, env });
|
|
63
|
+
expect(cdOutput.trim()).toBe(path.join(customRoot, 'vaults', '0', '1'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('prints names only for list in non-interactive mode', () => {
|
|
67
|
+
const projectDir = mkTempDir('archiver-e2e-list-');
|
|
68
|
+
const defaultFilePath = path.join(projectDir, 'list-file.txt');
|
|
69
|
+
const vaultFilePath = path.join(projectDir, 'list-file-in-work.txt');
|
|
70
|
+
fs.writeFileSync(defaultFilePath, 'list data\n', 'utf8');
|
|
71
|
+
fs.writeFileSync(vaultFilePath, 'list data in work\n', 'utf8');
|
|
72
|
+
|
|
73
|
+
const env = {
|
|
74
|
+
NODE_ENV: 'development',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
run(['config', 'update-check', 'off'], { cwd: projectDir, env });
|
|
78
|
+
run(['put', defaultFilePath], { cwd: projectDir, env });
|
|
79
|
+
run(['vault', 'create', 'work'], { cwd: projectDir, env });
|
|
80
|
+
run(['put', '--vault', 'work', vaultFilePath], { cwd: projectDir, env });
|
|
81
|
+
|
|
82
|
+
const output = run(['list'], { cwd: projectDir, env });
|
|
83
|
+
expect(output).toContain('list-file.txt');
|
|
84
|
+
expect(output).toContain('work(1)::list-file-in-work.txt');
|
|
85
|
+
expect(output).not.toContain('@(0)');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('emits cd marker instead of opening subshell for cd', () => {
|
|
89
|
+
const projectDir = mkTempDir('archiver-e2e-cd-marker-');
|
|
90
|
+
const filePath = path.join(projectDir, 'cd-file.txt');
|
|
91
|
+
fs.writeFileSync(filePath, 'cd data\n', 'utf8');
|
|
92
|
+
|
|
93
|
+
const env = {
|
|
94
|
+
NODE_ENV: 'development',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
run(['config', 'update-check', 'off'], { cwd: projectDir, env });
|
|
98
|
+
run(['put', filePath], { cwd: projectDir, env });
|
|
99
|
+
const output = run(['cd', '1'], { cwd: projectDir, env });
|
|
100
|
+
|
|
101
|
+
expect(output.trim()).toBe(`__ARCHIVER_CD__:${path.join(projectDir, '.archiver', 'vaults', '0', '1')}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const createdDirs: string[] = [];
|
|
7
|
+
|
|
8
|
+
export function cleanDirs() {
|
|
9
|
+
for (const dir of createdDirs.splice(0)) {
|
|
10
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function mkTempDir(prefix: string): string {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
16
|
+
createdDirs.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
function shellQuote(value: string): string {
|
|
20
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const entry = path.join(import.meta.dirname, '..', '..', 'src', 'index.ts');
|
|
24
|
+
export function run(
|
|
25
|
+
args: string[],
|
|
26
|
+
options: {
|
|
27
|
+
cwd: string;
|
|
28
|
+
env?: NodeJS.ProcessEnv;
|
|
29
|
+
},
|
|
30
|
+
): string {
|
|
31
|
+
const command = ['tsx', entry, ...args].join(' ');
|
|
32
|
+
return execSync(command, {
|
|
33
|
+
cwd: options.cwd,
|
|
34
|
+
env: {
|
|
35
|
+
...process.env,
|
|
36
|
+
...options.env,
|
|
37
|
+
},
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
|
+
maxBuffer: 1024 * 1024,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { ArchiveStatus, CheckIssueLevel, Paths } from '../../src/consts/index.js';
|
|
6
|
+
import { ArchiverContext } from '../../src/core/context.js';
|
|
7
|
+
import { ArchiveService } from '../../src/services/archive.js';
|
|
8
|
+
import { AuditLogger } from '../../src/services/audit-logger.js';
|
|
9
|
+
import { CheckService } from '../../src/services/check.js';
|
|
10
|
+
import { ConfigService } from '../../src/services/config.js';
|
|
11
|
+
import { VaultService } from '../../src/services/vault.js';
|
|
12
|
+
|
|
13
|
+
type PathsSnapshot = {
|
|
14
|
+
dir: Record<keyof typeof Paths.Dir, string>;
|
|
15
|
+
file: Record<keyof typeof Paths.File, string>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let snapshot: PathsSnapshot;
|
|
19
|
+
let sandboxRoot: string;
|
|
20
|
+
let workspaceDir: string;
|
|
21
|
+
|
|
22
|
+
function applyPaths(rootDir: string): void {
|
|
23
|
+
Object.assign(Paths.Dir, {
|
|
24
|
+
root: rootDir,
|
|
25
|
+
vaults: path.join(rootDir, 'vaults'),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
Object.assign(Paths.File, {
|
|
29
|
+
config: path.join(rootDir, 'config.jsonc'),
|
|
30
|
+
autoIncr: path.join(rootDir, 'auto-incr.json'),
|
|
31
|
+
list: path.join(rootDir, 'list.json'),
|
|
32
|
+
vaults: path.join(rootDir, 'vaults.json'),
|
|
33
|
+
log: path.join(rootDir, 'log.jsonl'),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function pathExists(targetPath: string): Promise<boolean> {
|
|
38
|
+
try {
|
|
39
|
+
await fs.access(targetPath);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function createRuntime(): Promise<{
|
|
47
|
+
context: ArchiverContext;
|
|
48
|
+
archiveService: ArchiveService;
|
|
49
|
+
vaultService: VaultService;
|
|
50
|
+
checkService: CheckService;
|
|
51
|
+
}> {
|
|
52
|
+
const context = new ArchiverContext();
|
|
53
|
+
await context.init();
|
|
54
|
+
|
|
55
|
+
const configService = new ConfigService(context);
|
|
56
|
+
const logger = new AuditLogger(context);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
context,
|
|
60
|
+
archiveService: new ArchiveService(context, configService, logger),
|
|
61
|
+
vaultService: new VaultService(context, configService),
|
|
62
|
+
checkService: new CheckService(context),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
snapshot = {
|
|
68
|
+
dir: { ...Paths.Dir },
|
|
69
|
+
file: { ...Paths.File },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'archiver-ts-test-'));
|
|
73
|
+
workspaceDir = path.join(sandboxRoot, 'workspace');
|
|
74
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
applyPaths(path.join(sandboxRoot, '.archiver'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
Object.assign(Paths.Dir, snapshot.dir);
|
|
81
|
+
Object.assign(Paths.File, snapshot.file);
|
|
82
|
+
|
|
83
|
+
if (sandboxRoot) {
|
|
84
|
+
await fs.rm(sandboxRoot, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('archive workflow', () => {
|
|
89
|
+
it('archives into <vault>/<id>/<originalName> and restores back', async () => {
|
|
90
|
+
const runtime = await createRuntime();
|
|
91
|
+
const sourceFile = path.join(workspaceDir, 'note.txt');
|
|
92
|
+
await fs.writeFile(sourceFile, 'hello archiver\n', 'utf8');
|
|
93
|
+
|
|
94
|
+
const putResult = await runtime.archiveService.put([sourceFile], {
|
|
95
|
+
message: 'save for later',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(putResult.failed).toHaveLength(0);
|
|
99
|
+
expect(putResult.ok).toHaveLength(1);
|
|
100
|
+
expect(putResult.ok[0]?.id).toBe(1);
|
|
101
|
+
|
|
102
|
+
const slotPath = runtime.context.archivePath(0, 1);
|
|
103
|
+
const archivedObjectPath = runtime.context.archiveObjectPath(0, 1, 'note.txt');
|
|
104
|
+
expect(await pathExists(sourceFile)).toBe(false);
|
|
105
|
+
expect(await pathExists(slotPath)).toBe(true);
|
|
106
|
+
expect(await pathExists(archivedObjectPath)).toBe(true);
|
|
107
|
+
expect(await pathExists(Paths.File.log)).toBe(true);
|
|
108
|
+
expect(await pathExists(path.join(Paths.Dir.root, 'logs'))).toBe(false);
|
|
109
|
+
|
|
110
|
+
const entriesAfterPut = await runtime.context.loadListEntries(true);
|
|
111
|
+
expect(entriesAfterPut).toHaveLength(1);
|
|
112
|
+
expect(entriesAfterPut[0]?.status).toBe(ArchiveStatus.Archived);
|
|
113
|
+
expect(entriesAfterPut[0]?.item).toBe('note.txt');
|
|
114
|
+
|
|
115
|
+
const restoreResult = await runtime.archiveService.restore([1]);
|
|
116
|
+
expect(restoreResult.failed).toHaveLength(0);
|
|
117
|
+
expect(restoreResult.ok).toHaveLength(1);
|
|
118
|
+
expect(await fs.readFile(sourceFile, 'utf8')).toBe('hello archiver\n');
|
|
119
|
+
expect(await pathExists(slotPath)).toBe(false);
|
|
120
|
+
|
|
121
|
+
const entriesAfterRestore = await runtime.context.loadListEntries(true);
|
|
122
|
+
expect(entriesAfterRestore[0]?.status).toBe(ArchiveStatus.Restored);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('moves archive slots between vaults and resolves cd targets', async () => {
|
|
126
|
+
const runtime = await createRuntime();
|
|
127
|
+
const sourceFile = path.join(workspaceDir, 'move-me.txt');
|
|
128
|
+
await fs.writeFile(sourceFile, 'move me\n', 'utf8');
|
|
129
|
+
|
|
130
|
+
await runtime.archiveService.put([sourceFile], {});
|
|
131
|
+
const createdVault = await runtime.vaultService.createVault({ name: 'work' });
|
|
132
|
+
const targetVault = createdVault.vault;
|
|
133
|
+
|
|
134
|
+
const moveResult = await runtime.archiveService.move([1], 'work');
|
|
135
|
+
expect(moveResult.failed).toHaveLength(0);
|
|
136
|
+
expect(moveResult.ok).toHaveLength(1);
|
|
137
|
+
|
|
138
|
+
const oldSlotPath = runtime.context.archivePath(0, 1);
|
|
139
|
+
const newSlotPath = runtime.context.archivePath(targetVault.id, 1);
|
|
140
|
+
const movedObjectPath = runtime.context.archiveObjectPath(targetVault.id, 1, 'move-me.txt');
|
|
141
|
+
|
|
142
|
+
expect(await pathExists(oldSlotPath)).toBe(false);
|
|
143
|
+
expect(await pathExists(newSlotPath)).toBe(true);
|
|
144
|
+
expect(await pathExists(movedObjectPath)).toBe(true);
|
|
145
|
+
|
|
146
|
+
const byId = await runtime.archiveService.resolveCdTarget('1');
|
|
147
|
+
expect(byId.slotPath).toBe(newSlotPath);
|
|
148
|
+
expect(byId.vault.id).toBe(targetVault.id);
|
|
149
|
+
|
|
150
|
+
const byVaultAndId = await runtime.archiveService.resolveCdTarget(`work/1`);
|
|
151
|
+
expect(byVaultAndId.slotPath).toBe(newSlotPath);
|
|
152
|
+
|
|
153
|
+
await expect(runtime.archiveService.resolveCdTarget(`@/1`)).rejects.toThrow(
|
|
154
|
+
`Archive id 1 is in vault ${targetVault.id}, not 0.`,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('check service reports non-directory numeric archive slots as errors', async () => {
|
|
159
|
+
const runtime = await createRuntime();
|
|
160
|
+
const sourceFile = path.join(workspaceDir, 'invalid-slot.txt');
|
|
161
|
+
await fs.writeFile(sourceFile, 'slot validation\n', 'utf8');
|
|
162
|
+
await runtime.archiveService.put([sourceFile], {});
|
|
163
|
+
|
|
164
|
+
const slotPath = runtime.context.archivePath(0, 1);
|
|
165
|
+
await fs.rm(slotPath, { recursive: true, force: true });
|
|
166
|
+
await fs.writeFile(slotPath, 'I should be a directory', 'utf8');
|
|
167
|
+
|
|
168
|
+
const report = await runtime.checkService.run();
|
|
169
|
+
const issueCodes = report.issues.map((issue) => issue.code);
|
|
170
|
+
expect(issueCodes).toContain('MISSING_ARCHIVE_OBJECT');
|
|
171
|
+
expect(issueCodes).toContain('INVALID_ARCHIVE_SLOT');
|
|
172
|
+
|
|
173
|
+
const invalidSlotIssue = report.issues.find((issue) => issue.code === 'INVALID_ARCHIVE_SLOT');
|
|
174
|
+
expect(invalidSlotIssue?.level).toBe(CheckIssueLevel.Error);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseIdList, parseLogRange, parseVaultReference } from '../../src/utils/parse.js';
|
|
3
|
+
|
|
4
|
+
describe('parseIdList', () => {
|
|
5
|
+
it('parses numeric ids and preserves order', () => {
|
|
6
|
+
expect(parseIdList(['12', '2', '99'])).toEqual([12, 2, 99]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('rejects duplicate ids', () => {
|
|
10
|
+
expect(() => parseIdList(['1', '1'])).toThrow('Duplicated ids are not allowed.');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('rejects non-numeric ids', () => {
|
|
14
|
+
expect(() => parseIdList(['1', 'abc'])).toThrow('Invalid id: abc');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('parseLogRange', () => {
|
|
19
|
+
it('parses default and all ranges', () => {
|
|
20
|
+
expect(parseLogRange()).toEqual({ mode: 'tail' });
|
|
21
|
+
expect(parseLogRange('all')).toEqual({ mode: 'all' });
|
|
22
|
+
expect(parseLogRange('*')).toEqual({ mode: 'all' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('parses month ranges', () => {
|
|
26
|
+
expect(parseLogRange('202601')).toEqual({ mode: 'month', from: '202601', to: '202601' });
|
|
27
|
+
expect(parseLogRange('202501-202512')).toEqual({ mode: 'month', from: '202501', to: '202512' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects invalid month format or order', () => {
|
|
31
|
+
expect(() => parseLogRange('202613')).toThrow('Invalid month range: 202613');
|
|
32
|
+
expect(() => parseLogRange('202512-202501')).toThrow('Invalid range order: 202512-202501');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('parseVaultReference', () => {
|
|
37
|
+
it('returns id for numeric reference', () => {
|
|
38
|
+
expect(parseVaultReference('10')).toEqual({ id: 10 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns name for non-numeric reference', () => {
|
|
42
|
+
expect(parseVaultReference('work')).toEqual({ name: 'work' });
|
|
43
|
+
});
|
|
44
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noImplicitOverride": true,
|
|
9
|
+
"noUncheckedIndexedAccess": false,
|
|
10
|
+
"exactOptionalPropertyTypes": false,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"declaration": true,
|
|
16
|
+
"sourceMap": true,
|
|
17
|
+
|
|
18
|
+
"outDir": "dist",
|
|
19
|
+
"baseUrl": ".",
|
|
20
|
+
"paths": {
|
|
21
|
+
"@/*": ["src/*"]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
|
25
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
include: ['tests/**/*.test.ts'],
|
|
7
|
+
fileParallelism: false,
|
|
8
|
+
clearMocks: true,
|
|
9
|
+
testTimeout: 30_000,
|
|
10
|
+
},
|
|
11
|
+
resolve: {
|
|
12
|
+
alias: {
|
|
13
|
+
'@': 'src',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
});
|