mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Harden safe fleet purge
This commit is contained in:
1 parent
f2c4e478c2
commit
35056507c8
10 files changed
+151
-34
No files matched your search
@@ -29,7 +29,9 @@ and the first Claude reference-fleet path:
|
||||
- read-only health diagnostics covering installation integrity, runtime
|
||||
availability, worktree drift count and age, Git operations, and honest
|
||||
recoverability grade;
|
||||
- conservative, reversible purge;
|
||||
- conservative, explicitly confirmed purge that validates the exact local
|
||||
state path and distinguishes owned history from live sessions, locks,
|
||||
leases, guards, landing, persistent launchers, and unknown files;
|
||||
- reviewed worktree plans with branch/path collision protection;
|
||||
- isolated branches and worktrees with locally ignored task markers;
|
||||
- purge protection for dirty and uniquely committed domain work.
|
||||
|
||||
@@ -411,6 +411,16 @@ No uninstall mode may delete or remove a worktree containing:
|
||||
|
||||
Uninstall MUST stop before destructive cleanup and report exact remediation.
|
||||
|
||||
Implementation checkpoint (2026-09-27): CLI purge requires both `--purge` and
|
||||
explicit `--yes` after a dry-run review. Reversal validates the manifest's
|
||||
machine-local state path against the project ID and current XDG environment,
|
||||
allows TORCH-owned historical database and evidence files, and fails closed on
|
||||
unknown state entries, active runtime identities, local locks, resource leases,
|
||||
worktree guards, in-flight landing, unsafe worktrees, unique canonical history,
|
||||
or installed persistent launchers. Historical messages and receipts therefore
|
||||
do not make reversal permanently impossible, while active or unowned state
|
||||
still blocks deletion.
|
||||
|
||||
## 10. Project analysis and bootstrap
|
||||
|
||||
### 10.1 Repository reconnaissance
|
||||
|
||||
@@ -9,7 +9,7 @@ test_integrity_note:
|
||||
- id: SCN-install-doctor-purge
|
||||
given: a reviewed fresh installation into a Git repository
|
||||
when: TORCH installs, diagnoses, and purges its unchanged state
|
||||
then: doctor reports healthy, all owned TORCH state is removed, and project source remains unchanged
|
||||
then: active runtime state blocks purge, historical durable state remains safely reversible after shutdown, all owned TORCH state is removed, and project source remains unchanged
|
||||
- id: SCN-existing-repository-preservation
|
||||
given: an existing repository with history, a long-lived branch, owner configuration, and an untracked file
|
||||
when: TORCH is installed and safely purged
|
||||
@@ -254,6 +254,7 @@ test_integrity_note:
|
||||
- measured and estimated context groups, cache-read ratio, and cost per verified item
|
||||
- schedule plan blockers, subprocess shell flag, retry count, and durable failure message
|
||||
- preservation of project source and modified owner configuration
|
||||
- explicit purge approval, exact XDG project-state path, and active versus historical local-state classification
|
||||
- SQLite state after process restart
|
||||
- CLI and MCP output over real process boundaries
|
||||
- runtime command plans and execution order
|
||||
@@ -290,6 +291,8 @@ test_integrity_note:
|
||||
- doctor that reports healthy without required state
|
||||
- doctor hiding convergence age, an active Git operation, a missing runtime, or one-disk-only recovery
|
||||
- uninstall that deletes owner-modified configuration
|
||||
- purge without explicit confirmation or through a redirected local-state path
|
||||
- historical messages making safe reversal permanently impossible
|
||||
- CLI that mutates without explicit approval
|
||||
- in-memory-only messaging that disappears on restart
|
||||
- runtime session IDs masquerading as durable Fleet identities
|
||||
|
||||
+7
-1
@@ -141,7 +141,7 @@ Usage:
|
||||
torch fleet activate --change <id> --by <owner> --yes [--json]
|
||||
torch fleet start --change <id> [--fresh] [--dry-run] --yes [--json]
|
||||
torch doctor [--json]
|
||||
torch uninstall [--dry-run] [--purge] [--json]
|
||||
torch uninstall [--dry-run] [--purge --yes] [--json]
|
||||
`;
|
||||
|
||||
function print(value, { json = false } = {}) {
|
||||
@@ -1149,10 +1149,16 @@ export async function runCli(argv = process.argv.slice(2), {
|
||||
}
|
||||
}
|
||||
if (command === 'uninstall') {
|
||||
if (argv.includes('--purge') && !argv.includes('--dry-run') && !argv.includes('--yes')) {
|
||||
throw new TorchError('Purging TORCH removes tracked configuration, managed worktrees, and local state. Review --dry-run, then re-run with --purge --yes.', {
|
||||
code: 'APPROVAL_REQUIRED',
|
||||
});
|
||||
}
|
||||
const result = uninstallProject({
|
||||
repository,
|
||||
purge: argv.includes('--purge'),
|
||||
dryRun: argv.includes('--dry-run'),
|
||||
env,
|
||||
});
|
||||
print(result, { json });
|
||||
return result.canProceed === false ? 1 : 0;
|
||||
|
||||
+82
-19
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
|
||||
existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { TorchError } from './errors.mjs';
|
||||
@@ -268,15 +269,74 @@ export function installProject({
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyEntries(path, ignoredRoots = []) {
|
||||
if (!existsSync(path)) return [];
|
||||
return readdirSync(path, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => resolve(entry.parentPath, entry.name))
|
||||
.filter((entry) => entry !== join(resolve(path), 'project.json'))
|
||||
.filter((entry) => !ignoredRoots.some((root) =>
|
||||
entry === resolve(root) || entry.startsWith(`${resolve(root)}${sep}`)))
|
||||
.map((entry) => relative(path, entry));
|
||||
function inspectLocalState(statePath, manifest, env, problems) {
|
||||
const expected = projectStatePath(manifest.projectId, env);
|
||||
if (resolve(statePath) !== resolve(expected)) {
|
||||
problems.push({ type: 'unsafe-local-state-path', recorded: statePath, expected });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!lstatSync(statePath).isDirectory() || realpathSync(statePath) !== resolve(statePath)) {
|
||||
problems.push({ type: 'unsafe-local-state-node', path: statePath });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
problems.push({ type: 'local-state-unreadable', path: statePath, message: error.message });
|
||||
return;
|
||||
}
|
||||
const allowedFiles = new Set(['project.json', 'state.db', 'state.db-shm', 'state.db-wal']);
|
||||
const allowedDirectories = new Set([
|
||||
'sessions', 'messages', 'locks', 'leases', 'checks', 'integration', 'logs', 'remote.git',
|
||||
]);
|
||||
const unexpected = readdirSync(statePath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory()
|
||||
? !allowedDirectories.has(entry.name) : !allowedFiles.has(entry.name))
|
||||
.map((entry) => entry.name);
|
||||
if (unexpected.length) problems.push({ type: 'unknown-local-state', path: statePath, entries: unexpected });
|
||||
const locksPath = join(statePath, 'locks');
|
||||
const locks = existsSync(locksPath) ? readdirSync(locksPath) : [];
|
||||
if (locks.length) problems.push({ type: 'active-local-locks', path: locksPath, entries: locks });
|
||||
const databasePath = join(statePath, 'state.db');
|
||||
if (!existsSync(databasePath)) return;
|
||||
let database;
|
||||
try {
|
||||
database = new DatabaseSync(databasePath, { readOnly: true });
|
||||
const hasTable = (name) => Boolean(database.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
).get(name));
|
||||
if (hasTable('identities')) {
|
||||
const active = database.prepare(`
|
||||
SELECT area_id AS areaId, state, runtime_session_id AS runtimeSessionId
|
||||
FROM identities WHERE state != 'offline' ORDER BY area_id
|
||||
`).all();
|
||||
if (active.length) problems.push({ type: 'active-runtime-sessions', sessions: active });
|
||||
}
|
||||
if (hasTable('resource_leases')) {
|
||||
const leases = database.prepare(`
|
||||
SELECT resource_id AS resourceId, area_id AS areaId, expires_at AS expiresAt
|
||||
FROM resource_leases WHERE released_at IS NULL ORDER BY resource_id, acquired_at
|
||||
`).all();
|
||||
if (leases.length) problems.push({ type: 'active-resource-leases', leases });
|
||||
}
|
||||
if (hasTable('worktree_guards')) {
|
||||
const guards = database.prepare(`
|
||||
SELECT area_id AS areaId, guard_type AS guardType, reason
|
||||
FROM worktree_guards WHERE released_at IS NULL ORDER BY created_at, id
|
||||
`).all();
|
||||
if (guards.length) problems.push({ type: 'active-worktree-guards', guards });
|
||||
}
|
||||
if (hasTable('integration_requests')) {
|
||||
const landing = database.prepare(`
|
||||
SELECT id, source_area AS sourceArea, source_commit AS sourceCommit
|
||||
FROM integration_requests WHERE state = 'landing' ORDER BY created_at, id
|
||||
`).all();
|
||||
if (landing.length) problems.push({ type: 'integration-landing-active', requests: landing });
|
||||
}
|
||||
} catch (error) {
|
||||
problems.push({ type: 'local-state-database-invalid', path: databasePath, message: error.message });
|
||||
} finally {
|
||||
database?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function gitOptional(root, args) {
|
||||
@@ -310,7 +370,7 @@ function inspectCanonicalRemoval(repositoryRoot, entry, localStatePath) {
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function planUninstall({ repository, purge = false }) {
|
||||
export function planUninstall({ repository, purge = false, env = process.env }) {
|
||||
const manifest = readInstallManifest(repository.root);
|
||||
const problems = [];
|
||||
let mainBranch = repository.branch || 'main';
|
||||
@@ -319,12 +379,19 @@ export function planUninstall({ repository, purge = false }) {
|
||||
mainBranch = config.project?.main_branch ?? mainBranch;
|
||||
} catch { /* created-file validation below reports the invalid config */ }
|
||||
const localState = (manifest.external ?? []).find((entry) => entry.type === 'local-state');
|
||||
if (localState && existsSync(join(localState.path, 'project.json'))) {
|
||||
if (localState && !existsSync(join(localState.path, 'project.json'))) {
|
||||
problems.push({ type: 'local-state-metadata-missing', path: join(localState.path, 'project.json') });
|
||||
} else if (localState) {
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(join(localState.path, 'project.json'), 'utf8'));
|
||||
if (resolve(metadata.root) !== resolve(repository.root)) {
|
||||
problems.push({ type: 'not-installation-root', expected: metadata.root, actual: repository.root });
|
||||
}
|
||||
if (metadata.projectId !== manifest.projectId) {
|
||||
problems.push({
|
||||
type: 'local-state-project-mismatch', expected: manifest.projectId, actual: metadata.projectId ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
problems.push({ type: 'local-state-metadata-invalid', path: localState.path });
|
||||
}
|
||||
@@ -336,11 +403,7 @@ export function planUninstall({ repository, purge = false }) {
|
||||
}
|
||||
for (const entry of manifest.external ?? []) {
|
||||
if (entry.type === 'local-state') {
|
||||
const canonicalRoots = (manifest.external ?? [])
|
||||
.filter((candidate) => candidate.type === 'canonical-remote')
|
||||
.map((candidate) => candidate.path);
|
||||
const extras = nonEmptyEntries(entry.path, canonicalRoots);
|
||||
if (extras.length) problems.push({ type: 'local-state-not-empty', path: entry.path, entries: extras });
|
||||
if (existsSync(entry.path)) inspectLocalState(entry.path, manifest, env, problems);
|
||||
}
|
||||
if (entry.type === 'canonical-remote') {
|
||||
if (!localState?.path) problems.push({ type: 'local-state-missing-for-canonical', path: entry.path });
|
||||
@@ -361,8 +424,8 @@ export function planUninstall({ repository, purge = false }) {
|
||||
};
|
||||
}
|
||||
|
||||
export function uninstallProject({ repository, purge = false, dryRun = false }) {
|
||||
const plan = planUninstall({ repository, purge });
|
||||
export function uninstallProject({ repository, purge = false, dryRun = false, env = process.env }) {
|
||||
const plan = planUninstall({ repository, purge, env });
|
||||
if (dryRun || !purge) return plan;
|
||||
if (!plan.canProceed) {
|
||||
throw new TorchError('TORCH purge stopped because managed state changed', {
|
||||
|
||||
@@ -149,7 +149,7 @@ test('SCN-bootstrap-acceptance: one installed fixture proves the complete organi
|
||||
assert.deepEqual(resume.actions.map((action) => action.runtimeSessionId), started.started.map((entry) => entry.runtimeSessionId));
|
||||
control.close();
|
||||
|
||||
const reversal = planUninstall({ repository: inspectRepository(context.root), purge: true });
|
||||
const reversal = planUninstall({ repository: inspectRepository(context.root), purge: true, env: context.env });
|
||||
assert.equal(reversal.mutationPerformed, false);
|
||||
assert.equal(reversal.manifest.external.filter((entry) => entry.type === 'worktree').length, roster.agents.length);
|
||||
assert.equal(reversal.problems.some((problem) => problem.type === 'unsafe-worktree'), true,
|
||||
|
||||
@@ -155,6 +155,9 @@ test('SCN-cli-lifecycle-surface: runtime selection, selective startup, capture,
|
||||
const doctor = run(root, ['doctor', '--json']);
|
||||
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
||||
assert.equal(JSON.parse(doctor.stdout).findings.some((finding) => finding.code === 'FLEET_DETACHED'), true);
|
||||
const unapprovedPurge = run(root, ['uninstall', '--purge', '--json']);
|
||||
assert.equal(unapprovedPurge.status, 2);
|
||||
assert.equal(JSON.parse(unapprovedPurge.stdout).error, 'APPROVAL_REQUIRED');
|
||||
});
|
||||
|
||||
test('SCN-cli-control-plane: CLI messages, acknowledgements, ownership, and status share durable state', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../src/kernel/install.mjs';
|
||||
import { proposeDomains, validateApprovedProposal } from '../../src/kernel/domains.mjs';
|
||||
import { createLocalCanonical } from '../../src/canonical/local.mjs';
|
||||
import { openControlPlane } from '../../src/control-plane/service.mjs';
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'torch-kernel-'));
|
||||
@@ -72,7 +73,16 @@ test('SCN-install-doctor-purge: a fresh install is healthy and exactly reversibl
|
||||
assert.equal(diagnosis.healthy, true);
|
||||
assert.equal(diagnosis.status, 'ok');
|
||||
|
||||
const result = uninstallProject({ repository: inspectRepository(root), purge: true });
|
||||
const control = openControlPlane({ repositoryRoot: root, env });
|
||||
control.sendMessage({ sender: 'session-manager', recipient: proposal.domains[0].id, body: 'Historical state remains reversible.' });
|
||||
control.reportStatus({ areaId: proposal.domains[0].id, state: 'working', summary: 'Still active.' });
|
||||
control.close();
|
||||
const activePlan = planUninstall({ repository: inspectRepository(root), purge: true, env });
|
||||
assert.equal(activePlan.problems.some((problem) => problem.type === 'active-runtime-sessions'), true);
|
||||
const reopened = openControlPlane({ repositoryRoot: root, env });
|
||||
reopened.reportStatus({ areaId: proposal.domains[0].id, state: 'offline', summary: 'Stopped.' });
|
||||
reopened.close();
|
||||
const result = uninstallProject({ repository: inspectRepository(root), purge: true, env });
|
||||
assert.equal(result.mutationPerformed, true);
|
||||
assert.equal(existsSync(join(root, '.torch')), false);
|
||||
assert.equal(existsSync(installed.stateRoot), false);
|
||||
@@ -93,7 +103,7 @@ test('SCN-existing-repository-preservation: install and purge preserve history,
|
||||
installProject({
|
||||
repository, proposal: approvedProposal(repository), env: externalEnv, projectId: 'existing-repository',
|
||||
});
|
||||
const purged = uninstallProject({ repository: inspectRepository(root), purge: true });
|
||||
const purged = uninstallProject({ repository: inspectRepository(root), purge: true, env: externalEnv });
|
||||
assert.equal(purged.mutationPerformed, true);
|
||||
assert.equal(readFileSync(join(root, '.projectrc'), 'utf8'), 'owner-setting=true\n');
|
||||
assert.equal(readFileSync(join(root, 'owner-notes.txt'), 'utf8'), 'untracked owner data\n');
|
||||
@@ -109,13 +119,33 @@ test('SCN-purge-protects-user-change: uninstall refuses to delete modified manag
|
||||
writeFileSync(configPath, `${readFileSync(configPath, 'utf8')}\n# owner change\n`);
|
||||
|
||||
assert.throws(
|
||||
() => uninstallProject({ repository: inspectRepository(root), purge: true }),
|
||||
() => uninstallProject({ repository: inspectRepository(root), purge: true, env }),
|
||||
(error) => error.code === 'UNSAFE_TO_PURGE'
|
||||
&& error.details.some((problem) => problem.type === 'modified' && problem.path === '.torch/torch.yaml'),
|
||||
);
|
||||
assert.equal(existsSync(configPath), true);
|
||||
});
|
||||
|
||||
test('SCN-purge-protects-local-identity: uninstall refuses mismatched machine-local metadata', () => {
|
||||
const { root, env } = fixture();
|
||||
const repository = inspectRepository(root);
|
||||
const installed = installProject({
|
||||
repository, proposal: approvedProposal(repository), env, projectId: 'expected-project',
|
||||
});
|
||||
const metadataPath = join(installed.stateRoot, 'project.json');
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
||||
writeFileSync(metadataPath, `${JSON.stringify({ ...metadata, projectId: 'different-project' }, null, 2)}\n`);
|
||||
|
||||
const plan = planUninstall({ repository: inspectRepository(root), purge: true, env });
|
||||
assert.equal(plan.canProceed, false);
|
||||
assert.equal(plan.problems.some((problem) => problem.type === 'local-state-project-mismatch'), true);
|
||||
assert.throws(
|
||||
() => uninstallProject({ repository: inspectRepository(root), purge: true, env }),
|
||||
(error) => error.code === 'UNSAFE_TO_PURGE',
|
||||
);
|
||||
assert.equal(existsSync(installed.stateRoot), true);
|
||||
});
|
||||
|
||||
test('SCN-domain-approval: installation rejects stale or unapproved organizational proposals', () => {
|
||||
const { root, env } = fixture();
|
||||
const repository = inspectRepository(root);
|
||||
@@ -180,15 +210,15 @@ test('SCN-canonical-reversal: owned local canonical state reverses only after un
|
||||
], { encoding: 'utf8' }).trim();
|
||||
execFileSync('git', ['--git-dir', canonical.path, 'update-ref', 'refs/heads/remote-only', uniqueCommit]);
|
||||
repository = inspectRepository(root);
|
||||
assert.equal(planUninstall({ repository, purge: true }).problems.some((problem) =>
|
||||
assert.equal(planUninstall({ repository, purge: true, env: externalEnv }).problems.some((problem) =>
|
||||
problem.type === 'canonical-unique-commit' && problem.commit === uniqueCommit), true);
|
||||
assert.throws(
|
||||
() => uninstallProject({ repository, purge: true }),
|
||||
() => uninstallProject({ repository, purge: true, env: externalEnv }),
|
||||
(error) => error.code === 'UNSAFE_TO_PURGE',
|
||||
);
|
||||
|
||||
execFileSync('git', ['--git-dir', canonical.path, 'update-ref', '-d', 'refs/heads/remote-only']);
|
||||
const purged = uninstallProject({ repository: inspectRepository(root), purge: true });
|
||||
const purged = uninstallProject({ repository: inspectRepository(root), purge: true, env: externalEnv });
|
||||
assert.equal(purged.mutationPerformed, true);
|
||||
assert.equal(existsSync(installed.stateRoot), false);
|
||||
assert.equal(execFileSync('git', ['-C', root, 'remote'], { encoding: 'utf8' }).includes('torch-canonical'), false);
|
||||
|
||||
@@ -133,7 +133,7 @@ test('SCN-system-schedule-launcher: exact-config user units install, dispatch, a
|
||||
['--user', 'daemon-reload'], ['--user', 'enable'],
|
||||
]);
|
||||
assert.equal(launcher.status().stale, false);
|
||||
assert.equal(planUninstall({ repository: inspectRepository(context.root), purge: true }).problems
|
||||
assert.equal(planUninstall({ repository: inspectRepository(context.root), purge: true, env: context.env }).problems
|
||||
.some((problem) => problem.type === 'persistent-integration-installed'), true);
|
||||
|
||||
const originalConfig = readFileSync(join(context.root, '.torch', 'torch.yaml'), 'utf8');
|
||||
|
||||
@@ -75,7 +75,7 @@ test('SCN-worktree-bootstrap: approved identities receive isolated branches and
|
||||
finding.code === 'RECOVERABILITY' && finding.level === 'ONE-DISK'), true);
|
||||
assert.equal(diagnosis.findings.some((finding) => finding.code.startsWith('RUNTIME_')), true);
|
||||
unlinkSync(mergeHead);
|
||||
uninstallProject({ repository: inspectRepository(fixture.root), purge: true });
|
||||
uninstallProject({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env });
|
||||
assert.equal(created.created.some((action) => existsSync(action.path)), false);
|
||||
assert.equal(readFileSync(join(fixture.root, '.git', 'info', 'exclude'), 'utf8').includes('.task'), false);
|
||||
});
|
||||
@@ -86,19 +86,19 @@ test('SCN-worktree-purge-safety: modified or uniquely committed domain work bloc
|
||||
const core = created.created.find((entry) => entry.area === 'core');
|
||||
writeFileSync(join(core.path, 'domain-work.txt'), 'not safe to delete\n');
|
||||
|
||||
const plan = planUninstall({ repository: inspectRepository(fixture.root), purge: true });
|
||||
const plan = planUninstall({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env });
|
||||
assert.equal(plan.canProceed, false);
|
||||
assert.equal(plan.problems.some((problem) =>
|
||||
problem.type === 'unsafe-worktree' && problem.area === 'core'
|
||||
&& problem.problems.includes('worktree-dirty')), true);
|
||||
execFileSync('git', ['-C', core.path, 'add', 'domain-work.txt']);
|
||||
execFileSync('git', ['-C', core.path, 'commit', '-m', 'domain work']);
|
||||
const committedPlan = planUninstall({ repository: inspectRepository(fixture.root), purge: true });
|
||||
const committedPlan = planUninstall({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env });
|
||||
assert.equal(committedPlan.problems.some((problem) =>
|
||||
problem.type === 'unsafe-worktree' && problem.area === 'core'
|
||||
&& problem.problems.includes('unique-commits:1')), true);
|
||||
assert.throws(
|
||||
() => uninstallProject({ repository: inspectRepository(fixture.root), purge: true }),
|
||||
() => uninstallProject({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env }),
|
||||
(error) => error.code === 'UNSAFE_TO_PURGE',
|
||||
);
|
||||
assert.equal(existsSync(join(core.path, 'domain-work.txt')), true);
|
||||
|
||||
Reference in new issue
Block a user