sh3-core 0.20.1 → 0.20.2
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/documents/backends.d.ts +12 -0
- package/dist/documents/backends.js +230 -3
- package/dist/documents/backends.test.js +147 -1
- package/dist/documents/config.d.ts +2 -0
- package/dist/documents/config.js +4 -0
- package/dist/documents/handle.js +40 -0
- package/dist/documents/handle.test.js +88 -1
- package/dist/documents/http-backend.d.ts +6 -0
- package/dist/documents/http-backend.js +61 -0
- package/dist/documents/http-backend.test.js +51 -1
- package/dist/documents/picker-api.test.js +2 -2
- package/dist/documents/types.d.ts +76 -14
- package/dist/documents/types.js +4 -0
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +6 -2
- package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
- package/dist/primitives/widgets/DocumentFilePicker.svelte +23 -1
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +14 -0
- package/dist/primitives/widgets/DocumentFilePicker.test.d.ts +1 -0
- package/dist/primitives/widgets/DocumentFilePicker.test.js +33 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +20 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +14 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +13 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +414 -27
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.test.js +277 -0
- package/dist/primitives/widgets/_FolderConfirmDelete.svelte +57 -0
- package/dist/primitives/widgets/_FolderConfirmDelete.svelte.d.ts +12 -0
- package/dist/sh3Api/headless.js +10 -0
- package/dist/shards/activate.svelte.js +2 -2
- package/dist/shell-shard/Terminal.svelte +4 -1
- package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
- package/dist/shell-shard/dispatch.d.ts +2 -0
- package/dist/shell-shard/dispatch.js +2 -0
- package/dist/shell-shard/manifest.js +7 -1
- package/dist/shell-shard/shellShard.svelte.js +1 -1
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +35 -0
- package/dist/shell-shard/verbs/cat.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cat.test.js +49 -0
- package/dist/shell-shard/verbs/index.js +12 -0
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +48 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +64 -0
- package/dist/shell-shard/verbs/mkdir.d.ts +2 -0
- package/dist/shell-shard/verbs/mkdir.js +30 -0
- package/dist/shell-shard/verbs/mkdir.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mkdir.test.js +48 -0
- package/dist/shell-shard/verbs/mv.d.ts +2 -0
- package/dist/shell-shard/verbs/mv.js +33 -0
- package/dist/shell-shard/verbs/mv.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mv.test.js +55 -0
- package/dist/shell-shard/verbs/rm.d.ts +2 -0
- package/dist/shell-shard/verbs/rm.js +28 -0
- package/dist/shell-shard/verbs/rm.test.d.ts +1 -0
- package/dist/shell-shard/verbs/rm.test.js +47 -0
- package/dist/shell-shard/verbs/scope-parse.d.ts +7 -0
- package/dist/shell-shard/verbs/scope-parse.js +33 -0
- package/dist/shell-shard/verbs/scope-parse.test.d.ts +1 -0
- package/dist/shell-shard/verbs/scope-parse.test.js +76 -0
- package/dist/shell-shard/verbs/xfer.d.ts +2 -0
- package/dist/shell-shard/verbs/xfer.js +101 -0
- package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
- package/dist/shell-shard/verbs/xfer.test.js +96 -0
- package/dist/verbs/types.d.ts +18 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { parseScopePath } from './scope-parse';
|
|
2
|
+
export const lsVerb = {
|
|
3
|
+
name: 'ls',
|
|
4
|
+
summary: 'List documents. Usage: ls | ls <shardId> | ls <shardId>/<prefix>',
|
|
5
|
+
programmatic: true,
|
|
6
|
+
async run(ctx, args) {
|
|
7
|
+
var _a, _b, _c;
|
|
8
|
+
const ts = Date.now();
|
|
9
|
+
if (!ctx.docs) {
|
|
10
|
+
ctx.scrollback.push({ kind: 'status', text: 'ls: document capability not available', level: 'error', ts });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const all = await ctx.docs.listDocuments();
|
|
14
|
+
const arg = args[0];
|
|
15
|
+
if (!arg) {
|
|
16
|
+
// Group by shard, emit counts
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
for (const doc of all) {
|
|
19
|
+
counts.set(doc.shardId, ((_a = counts.get(doc.shardId)) !== null && _a !== void 0 ? _a : 0) + 1);
|
|
20
|
+
}
|
|
21
|
+
if (counts.size === 0) {
|
|
22
|
+
ctx.scrollback.push({ kind: 'status', text: 'ls: no documents in active scope', level: 'info', ts });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const lines = [...counts.entries()]
|
|
26
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
27
|
+
.map(([shard, count]) => ` ${shard.padEnd(24)} ${count} doc${count !== 1 ? 's' : ''}`);
|
|
28
|
+
ctx.scrollback.push({ kind: 'text', stream: 'stdout', chunks: [lines.join('\n') + '\n'], ts });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const parsed = parseScopePath(arg);
|
|
32
|
+
const shardId = (_b = parsed === null || parsed === void 0 ? void 0 : parsed.shardId) !== null && _b !== void 0 ? _b : arg;
|
|
33
|
+
const prefix = (_c = parsed === null || parsed === void 0 ? void 0 : parsed.path) !== null && _c !== void 0 ? _c : '';
|
|
34
|
+
const docs = all.filter((d) => {
|
|
35
|
+
if (d.shardId !== shardId)
|
|
36
|
+
return false;
|
|
37
|
+
return prefix ? d.path.startsWith(prefix) : true;
|
|
38
|
+
});
|
|
39
|
+
if (docs.length === 0) {
|
|
40
|
+
ctx.scrollback.push({ kind: 'status', text: `ls: no documents in ${arg}`, level: 'info', ts });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const lines = docs
|
|
44
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
45
|
+
.map((d) => ` ${d.path}`);
|
|
46
|
+
ctx.scrollback.push({ kind: 'text', stream: 'stdout', chunks: [lines.join('\n') + '\n'], ts });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { lsVerb } from './ls';
|
|
3
|
+
function makeDocs(overrides = {}) {
|
|
4
|
+
return Object.assign({ listDocuments: vi.fn(async () => []), listShards: vi.fn(async () => []), watchDocuments: vi.fn(() => () => { }) }, overrides);
|
|
5
|
+
}
|
|
6
|
+
function makeCtx(docs) {
|
|
7
|
+
const pushed = [];
|
|
8
|
+
const ctx = {
|
|
9
|
+
sh3: {},
|
|
10
|
+
scrollback: { push: (e) => pushed.push(e) },
|
|
11
|
+
session: {},
|
|
12
|
+
cwd: '/',
|
|
13
|
+
fs: {},
|
|
14
|
+
docs,
|
|
15
|
+
dispatch: async () => { },
|
|
16
|
+
};
|
|
17
|
+
return { ctx, pushed };
|
|
18
|
+
}
|
|
19
|
+
const sampleDocs = [
|
|
20
|
+
{ shardId: 'notes', path: 'draft.md', size: 100, lastModified: 0 },
|
|
21
|
+
{ shardId: 'notes', path: 'ideas/spark.md', size: 50, lastModified: 0 },
|
|
22
|
+
{ shardId: 'design', path: 'spec.guml', size: 200, lastModified: 0 },
|
|
23
|
+
];
|
|
24
|
+
describe('ls verb', () => {
|
|
25
|
+
it('emits error when docs capability missing', async () => {
|
|
26
|
+
const { ctx, pushed } = makeCtx(undefined);
|
|
27
|
+
await lsVerb.run(ctx, []);
|
|
28
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
29
|
+
expect(err).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
it('lists shards with doc counts when no args', async () => {
|
|
32
|
+
const docs = makeDocs({ listDocuments: vi.fn(async () => sampleDocs) });
|
|
33
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
34
|
+
await lsVerb.run(ctx, []);
|
|
35
|
+
const text = pushed.find((e) => e.kind === 'text');
|
|
36
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).toMatch(/notes/);
|
|
37
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).toMatch(/design/);
|
|
38
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).toMatch(/2/); // notes has 2 docs
|
|
39
|
+
});
|
|
40
|
+
it('lists docs in a specific shard', async () => {
|
|
41
|
+
const docs = makeDocs({ listDocuments: vi.fn(async () => sampleDocs) });
|
|
42
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
43
|
+
await lsVerb.run(ctx, ['notes']);
|
|
44
|
+
const text = pushed.find((e) => e.kind === 'text');
|
|
45
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).toMatch(/draft\.md/);
|
|
46
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).toMatch(/ideas\/spark\.md/);
|
|
47
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).not.toMatch(/spec\.guml/);
|
|
48
|
+
});
|
|
49
|
+
it('filters docs by prefix when path given', async () => {
|
|
50
|
+
const docs = makeDocs({ listDocuments: vi.fn(async () => sampleDocs) });
|
|
51
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
52
|
+
await lsVerb.run(ctx, ['notes/ideas']);
|
|
53
|
+
const text = pushed.find((e) => e.kind === 'text');
|
|
54
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).toMatch(/spark\.md/);
|
|
55
|
+
expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).not.toMatch(/draft\.md/);
|
|
56
|
+
});
|
|
57
|
+
it('emits a status when shard has no docs', async () => {
|
|
58
|
+
const docs = makeDocs({ listDocuments: vi.fn(async () => sampleDocs) });
|
|
59
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
60
|
+
await lsVerb.run(ctx, ['empty-shard']);
|
|
61
|
+
const status = pushed.find((e) => e.kind === 'status' && e.level === 'info');
|
|
62
|
+
expect(status).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseScopePath } from './scope-parse';
|
|
2
|
+
export const mkdirVerb = {
|
|
3
|
+
name: 'mkdir',
|
|
4
|
+
summary: 'Create a folder. Usage: mkdir <shardId>/<folder>',
|
|
5
|
+
programmatic: true,
|
|
6
|
+
async run(ctx, args) {
|
|
7
|
+
const ts = Date.now();
|
|
8
|
+
if (!ctx.docs) {
|
|
9
|
+
ctx.scrollback.push({ kind: 'status', text: 'mkdir: document capability not available', level: 'error', ts });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (!args[0]) {
|
|
13
|
+
ctx.scrollback.push({ kind: 'status', text: 'usage: mkdir <shardId>/<folder>', level: 'error', ts });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!ctx.docs.writeTo) {
|
|
17
|
+
ctx.scrollback.push({ kind: 'status', text: 'mkdir: write permission not granted', level: 'error', ts });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const parsed = parseScopePath(args[0]);
|
|
21
|
+
if (!parsed || !parsed.path) {
|
|
22
|
+
ctx.scrollback.push({ kind: 'status', text: `mkdir: invalid path '${args[0]}'`, level: 'error', ts });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Materialise the folder with a sentinel file; backends treat paths as flat.
|
|
26
|
+
const keepPath = parsed.path.replace(/\/$/, '') + '/.keep';
|
|
27
|
+
await ctx.docs.writeTo(parsed.shardId, keepPath, '');
|
|
28
|
+
ctx.scrollback.push({ kind: 'status', text: `mkdir: created ${args[0]}`, level: 'info', ts });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mkdirVerb } from './mkdir';
|
|
3
|
+
function makeDocs(overrides = {}) {
|
|
4
|
+
return Object.assign({ listDocuments: vi.fn(async () => []), listShards: vi.fn(async () => []), watchDocuments: vi.fn(() => () => { }), writeTo: vi.fn(async () => { }) }, overrides);
|
|
5
|
+
}
|
|
6
|
+
function makeCtx(docs) {
|
|
7
|
+
const pushed = [];
|
|
8
|
+
const ctx = {
|
|
9
|
+
sh3: {},
|
|
10
|
+
scrollback: { push: (e) => pushed.push(e) },
|
|
11
|
+
session: {},
|
|
12
|
+
cwd: '/',
|
|
13
|
+
fs: {},
|
|
14
|
+
docs,
|
|
15
|
+
dispatch: async () => { },
|
|
16
|
+
};
|
|
17
|
+
return { ctx, pushed };
|
|
18
|
+
}
|
|
19
|
+
describe('mkdir verb', () => {
|
|
20
|
+
it('emits error when docs capability missing', async () => {
|
|
21
|
+
const { ctx, pushed } = makeCtx(undefined);
|
|
22
|
+
await mkdirVerb.run(ctx, ['notes/ideas']);
|
|
23
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
24
|
+
expect(err).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
it('emits usage error when no args', async () => {
|
|
27
|
+
const { ctx, pushed } = makeCtx(makeDocs());
|
|
28
|
+
await mkdirVerb.run(ctx, []);
|
|
29
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
30
|
+
expect(err.text).toMatch(/usage/i);
|
|
31
|
+
});
|
|
32
|
+
it('creates a folder via writeTo', async () => {
|
|
33
|
+
const writeTo = vi.fn(async () => { });
|
|
34
|
+
const docs = makeDocs({ writeTo });
|
|
35
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
36
|
+
await mkdirVerb.run(ctx, ['notes/ideas']);
|
|
37
|
+
expect(writeTo).toHaveBeenCalledWith('notes', 'ideas/.keep', '');
|
|
38
|
+
const ok = pushed.find((e) => e.kind === 'status' && e.level === 'info');
|
|
39
|
+
expect(ok).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
it('emits error when writeTo not available', async () => {
|
|
42
|
+
const docs = makeDocs({ writeTo: undefined });
|
|
43
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
44
|
+
await mkdirVerb.run(ctx, ['notes/ideas']);
|
|
45
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
46
|
+
expect(err).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { parseScopePath } from './scope-parse';
|
|
2
|
+
export const mvVerb = {
|
|
3
|
+
name: 'mv',
|
|
4
|
+
summary: 'Rename a document within a shard. Usage: mv <shardId>/<old> <shardId>/<new>',
|
|
5
|
+
programmatic: true,
|
|
6
|
+
async run(ctx, args) {
|
|
7
|
+
const ts = Date.now();
|
|
8
|
+
if (!ctx.docs) {
|
|
9
|
+
ctx.scrollback.push({ kind: 'status', text: 'mv: document capability not available', level: 'error', ts });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (args.length < 2) {
|
|
13
|
+
ctx.scrollback.push({ kind: 'status', text: 'usage: mv <shardId>/<old> <shardId>/<new>', level: 'error', ts });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!ctx.docs.renameFrom) {
|
|
17
|
+
ctx.scrollback.push({ kind: 'status', text: 'mv: write permission not granted', level: 'error', ts });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const src = parseScopePath(args[0]);
|
|
21
|
+
const dst = parseScopePath(args[1]);
|
|
22
|
+
if (!(src === null || src === void 0 ? void 0 : src.path) || !(dst === null || dst === void 0 ? void 0 : dst.path)) {
|
|
23
|
+
ctx.scrollback.push({ kind: 'status', text: 'mv: invalid path', level: 'error', ts });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (src.shardId !== dst.shardId) {
|
|
27
|
+
ctx.scrollback.push({ kind: 'status', text: 'mv: src and dst must be in the same shard (use xfer for cross-scope moves)', level: 'error', ts });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await ctx.docs.renameFrom(src.shardId, src.path, dst.path);
|
|
31
|
+
ctx.scrollback.push({ kind: 'status', text: `mv: renamed ${args[0]} → ${args[1]}`, level: 'info', ts });
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mvVerb } from './mv';
|
|
3
|
+
function makeDocs(overrides = {}) {
|
|
4
|
+
return Object.assign({ listDocuments: vi.fn(async () => []), listShards: vi.fn(async () => []), watchDocuments: vi.fn(() => () => { }), renameFrom: vi.fn(async () => { }) }, overrides);
|
|
5
|
+
}
|
|
6
|
+
function makeCtx(docs) {
|
|
7
|
+
const pushed = [];
|
|
8
|
+
const ctx = {
|
|
9
|
+
sh3: {},
|
|
10
|
+
scrollback: { push: (e) => pushed.push(e) },
|
|
11
|
+
session: {},
|
|
12
|
+
cwd: '/',
|
|
13
|
+
fs: {},
|
|
14
|
+
docs,
|
|
15
|
+
dispatch: async () => { },
|
|
16
|
+
};
|
|
17
|
+
return { ctx, pushed };
|
|
18
|
+
}
|
|
19
|
+
describe('mv verb', () => {
|
|
20
|
+
it('emits error when docs capability missing', async () => {
|
|
21
|
+
const { ctx, pushed } = makeCtx(undefined);
|
|
22
|
+
await mvVerb.run(ctx, ['notes/old.md', 'notes/new.md']);
|
|
23
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
24
|
+
expect(err).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
it('emits usage error when fewer than two args', async () => {
|
|
27
|
+
const { ctx, pushed } = makeCtx(makeDocs());
|
|
28
|
+
await mvVerb.run(ctx, ['notes/old.md']);
|
|
29
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
30
|
+
expect(err.text).toMatch(/usage/i);
|
|
31
|
+
});
|
|
32
|
+
it('renames a document within the same shard', async () => {
|
|
33
|
+
const renameFrom = vi.fn(async () => { });
|
|
34
|
+
const docs = makeDocs({ renameFrom });
|
|
35
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
36
|
+
await mvVerb.run(ctx, ['notes/old.md', 'notes/new.md']);
|
|
37
|
+
expect(renameFrom).toHaveBeenCalledWith('notes', 'old.md', 'new.md');
|
|
38
|
+
const ok = pushed.find((e) => e.kind === 'status' && e.level === 'info');
|
|
39
|
+
expect(ok).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
it('emits error when src and dst are in different shards', async () => {
|
|
42
|
+
const { ctx, pushed } = makeCtx(makeDocs());
|
|
43
|
+
await mvVerb.run(ctx, ['notes/old.md', 'design/new.md']);
|
|
44
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
45
|
+
expect(err).toBeDefined();
|
|
46
|
+
expect(err.text).toMatch(/same shard/i);
|
|
47
|
+
});
|
|
48
|
+
it('emits error when renameFrom not available', async () => {
|
|
49
|
+
const docs = makeDocs({ renameFrom: undefined });
|
|
50
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
51
|
+
await mvVerb.run(ctx, ['notes/old.md', 'notes/new.md']);
|
|
52
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
53
|
+
expect(err).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseScopePath } from './scope-parse';
|
|
2
|
+
export const rmVerb = {
|
|
3
|
+
name: 'rm',
|
|
4
|
+
summary: 'Delete a document. Usage: rm <shardId>/<path>',
|
|
5
|
+
programmatic: true,
|
|
6
|
+
async run(ctx, args) {
|
|
7
|
+
const ts = Date.now();
|
|
8
|
+
if (!ctx.docs) {
|
|
9
|
+
ctx.scrollback.push({ kind: 'status', text: 'rm: document capability not available', level: 'error', ts });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (!args[0]) {
|
|
13
|
+
ctx.scrollback.push({ kind: 'status', text: 'usage: rm <shardId>/<path>', level: 'error', ts });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!ctx.docs.deleteFrom) {
|
|
17
|
+
ctx.scrollback.push({ kind: 'status', text: 'rm: write permission not granted', level: 'error', ts });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const parsed = parseScopePath(args[0]);
|
|
21
|
+
if (!parsed || !parsed.path) {
|
|
22
|
+
ctx.scrollback.push({ kind: 'status', text: `rm: invalid path '${args[0]}'`, level: 'error', ts });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await ctx.docs.deleteFrom(parsed.shardId, parsed.path);
|
|
26
|
+
ctx.scrollback.push({ kind: 'status', text: `rm: deleted ${args[0]}`, level: 'info', ts });
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { rmVerb } from './rm';
|
|
3
|
+
function makeDocs(overrides = {}) {
|
|
4
|
+
return Object.assign({ listDocuments: vi.fn(async () => []), listShards: vi.fn(async () => []), watchDocuments: vi.fn(() => () => { }), deleteFrom: vi.fn(async () => { }) }, overrides);
|
|
5
|
+
}
|
|
6
|
+
function makeCtx(docs) {
|
|
7
|
+
const pushed = [];
|
|
8
|
+
const ctx = {
|
|
9
|
+
sh3: {},
|
|
10
|
+
scrollback: { push: (e) => pushed.push(e) },
|
|
11
|
+
session: {},
|
|
12
|
+
cwd: '/',
|
|
13
|
+
fs: {},
|
|
14
|
+
docs,
|
|
15
|
+
dispatch: async () => { },
|
|
16
|
+
};
|
|
17
|
+
return { ctx, pushed };
|
|
18
|
+
}
|
|
19
|
+
describe('rm verb', () => {
|
|
20
|
+
it('emits error when docs capability missing', async () => {
|
|
21
|
+
const { ctx, pushed } = makeCtx(undefined);
|
|
22
|
+
await rmVerb.run(ctx, ['notes/draft.md']);
|
|
23
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
24
|
+
expect(err).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
it('emits usage error when no args', async () => {
|
|
27
|
+
const { ctx, pushed } = makeCtx(makeDocs());
|
|
28
|
+
await rmVerb.run(ctx, []);
|
|
29
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
30
|
+
expect(err.text).toMatch(/usage/i);
|
|
31
|
+
});
|
|
32
|
+
it('deletes a document', async () => {
|
|
33
|
+
const docs = makeDocs({ deleteFrom: vi.fn(async () => { }) });
|
|
34
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
35
|
+
await rmVerb.run(ctx, ['notes/draft.md']);
|
|
36
|
+
expect(docs.deleteFrom).toHaveBeenCalledWith('notes', 'draft.md');
|
|
37
|
+
const ok = pushed.find((e) => e.kind === 'status' && e.level === 'info');
|
|
38
|
+
expect(ok).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
it('emits error when deleteFrom not available on capability', async () => {
|
|
41
|
+
const docs = makeDocs({ deleteFrom: undefined });
|
|
42
|
+
const { ctx, pushed } = makeCtx(docs);
|
|
43
|
+
await rmVerb.run(ctx, ['notes/draft.md']);
|
|
44
|
+
const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
|
|
45
|
+
expect(err).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ParsedScopePath {
|
|
2
|
+
scope: string | null;
|
|
3
|
+
shardId: string;
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function parseScopePath(raw: string): ParsedScopePath | null;
|
|
7
|
+
export declare function resolveScope(alias: string | null, activeId: string, personalId: string): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function parseScopePath(raw) {
|
|
2
|
+
if (!raw)
|
|
3
|
+
return null;
|
|
4
|
+
let scope = null;
|
|
5
|
+
let rest = raw;
|
|
6
|
+
const colonIdx = raw.indexOf(':');
|
|
7
|
+
if (colonIdx > 0) {
|
|
8
|
+
const prefix = raw.slice(0, colonIdx);
|
|
9
|
+
if (prefix === '@me' || prefix.startsWith('@project-')) {
|
|
10
|
+
scope = prefix;
|
|
11
|
+
rest = raw.slice(colonIdx + 1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (!rest)
|
|
15
|
+
return null;
|
|
16
|
+
const slashIdx = rest.indexOf('/');
|
|
17
|
+
if (slashIdx < 0) {
|
|
18
|
+
return { scope, shardId: rest, path: '' };
|
|
19
|
+
}
|
|
20
|
+
const shardId = rest.slice(0, slashIdx);
|
|
21
|
+
if (!shardId)
|
|
22
|
+
return null;
|
|
23
|
+
return { scope, shardId, path: rest.slice(slashIdx + 1) };
|
|
24
|
+
}
|
|
25
|
+
export function resolveScope(alias, activeId, personalId) {
|
|
26
|
+
if (alias === null)
|
|
27
|
+
return activeId;
|
|
28
|
+
if (alias === '@me')
|
|
29
|
+
return personalId;
|
|
30
|
+
if (alias.startsWith('@project-'))
|
|
31
|
+
return alias.slice('@project-'.length);
|
|
32
|
+
throw new Error(`Unknown scope alias: ${alias}`);
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseScopePath, resolveScope } from './scope-parse';
|
|
3
|
+
describe('parseScopePath', () => {
|
|
4
|
+
it('parses a bare shard/path', () => {
|
|
5
|
+
expect(parseScopePath('notes/foo/bar.md')).toEqual({
|
|
6
|
+
scope: null,
|
|
7
|
+
shardId: 'notes',
|
|
8
|
+
path: 'foo/bar.md',
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
it('parses a shard root (no slash)', () => {
|
|
12
|
+
expect(parseScopePath('notes')).toEqual({
|
|
13
|
+
scope: null,
|
|
14
|
+
shardId: 'notes',
|
|
15
|
+
path: '',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it('parses @me: prefix', () => {
|
|
19
|
+
expect(parseScopePath('@me:notes/draft.md')).toEqual({
|
|
20
|
+
scope: '@me',
|
|
21
|
+
shardId: 'notes',
|
|
22
|
+
path: 'draft.md',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it('parses @project-slug: prefix', () => {
|
|
26
|
+
expect(parseScopePath('@project-acme:design/spec.md')).toEqual({
|
|
27
|
+
scope: '@project-acme',
|
|
28
|
+
shardId: 'design',
|
|
29
|
+
path: 'spec.md',
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it('parses @project-compound-slug: prefix', () => {
|
|
33
|
+
expect(parseScopePath('@project-notes-v2:shard/path.md')).toEqual({
|
|
34
|
+
scope: '@project-notes-v2',
|
|
35
|
+
shardId: 'shard',
|
|
36
|
+
path: 'path.md',
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
it('parses @me: with shard root only', () => {
|
|
40
|
+
expect(parseScopePath('@me:notes')).toEqual({
|
|
41
|
+
scope: '@me',
|
|
42
|
+
shardId: 'notes',
|
|
43
|
+
path: '',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
it('returns null for empty string', () => {
|
|
47
|
+
expect(parseScopePath('')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it('returns null when scope prefix has no shard', () => {
|
|
50
|
+
expect(parseScopePath('@me:')).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
it('treats unknown @-prefix as part of shard name (no scope)', () => {
|
|
53
|
+
// @unknown is not @me or @project-*, so no scope stripping
|
|
54
|
+
const result = parseScopePath('@unknown:shard/path');
|
|
55
|
+
expect(result === null || result === void 0 ? void 0 : result.scope).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('resolveScope', () => {
|
|
59
|
+
const active = 'proj-tenant';
|
|
60
|
+
const personal = 'user-tenant';
|
|
61
|
+
it('returns active scope for null alias', () => {
|
|
62
|
+
expect(resolveScope(null, active, personal)).toBe(active);
|
|
63
|
+
});
|
|
64
|
+
it('returns personal id for @me', () => {
|
|
65
|
+
expect(resolveScope('@me', active, personal)).toBe(personal);
|
|
66
|
+
});
|
|
67
|
+
it('strips @project- prefix to get project tenant id', () => {
|
|
68
|
+
expect(resolveScope('@project-acme', active, personal)).toBe('acme');
|
|
69
|
+
});
|
|
70
|
+
it('handles multi-segment project slug', () => {
|
|
71
|
+
expect(resolveScope('@project-notes-v2', active, personal)).toBe('notes-v2');
|
|
72
|
+
});
|
|
73
|
+
it('throws for unknown alias', () => {
|
|
74
|
+
expect(() => resolveScope('@unknown', active, personal)).toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseScopePath, resolveScope } from './scope-parse';
|
|
2
|
+
export const xferVerb = {
|
|
3
|
+
name: 'xfer',
|
|
4
|
+
summary: [
|
|
5
|
+
'Transfer docs across scopes. Usage: xfer [-R] [-C] <src> <dst>',
|
|
6
|
+
' Scopes: @me | @project-<slug> (e.g. @project-acme:notes/draft.md)',
|
|
7
|
+
' -R recursive (src is a folder prefix)',
|
|
8
|
+
' -C copy only, do not delete source',
|
|
9
|
+
].join('\n'),
|
|
10
|
+
programmatic: true,
|
|
11
|
+
async run(ctx, args) {
|
|
12
|
+
const ts = Date.now();
|
|
13
|
+
if (!ctx.docs) {
|
|
14
|
+
ctx.scrollback.push({ kind: 'status', text: 'xfer: document capability not available', level: 'error', ts });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const scope = ctx.sh3.getActiveScope();
|
|
18
|
+
if (!scope.isProject) {
|
|
19
|
+
ctx.scrollback.push({ kind: 'status', text: 'xfer: only available when a project scope is active', level: 'error', ts });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// Parse flags
|
|
23
|
+
let recursive = false;
|
|
24
|
+
let copy = false;
|
|
25
|
+
const positional = [];
|
|
26
|
+
for (const a of args) {
|
|
27
|
+
if (a === '-R') {
|
|
28
|
+
recursive = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (a === '-C') {
|
|
32
|
+
copy = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
positional.push(a);
|
|
36
|
+
}
|
|
37
|
+
if (positional.length < 2) {
|
|
38
|
+
ctx.scrollback.push({ kind: 'status', text: 'usage: xfer [-R] [-C] <src> <dst>', level: 'error', ts });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!ctx.docs.transferToScope) {
|
|
42
|
+
ctx.scrollback.push({ kind: 'status', text: 'xfer: write permission not granted', level: 'error', ts });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const srcParsed = parseScopePath(positional[0]);
|
|
46
|
+
const dstParsed = parseScopePath(positional[1]);
|
|
47
|
+
if (!srcParsed || !dstParsed) {
|
|
48
|
+
ctx.scrollback.push({ kind: 'status', text: 'xfer: invalid path', level: 'error', ts });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let srcTenant;
|
|
52
|
+
let dstTenant;
|
|
53
|
+
try {
|
|
54
|
+
srcTenant = resolveScope(srcParsed.scope, scope.id, scope.personalId);
|
|
55
|
+
dstTenant = resolveScope(dstParsed.scope, scope.id, scope.personalId);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
ctx.scrollback.push({ kind: 'status', text: `xfer: ${e.message}`, level: 'error', ts });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// transferToScope always reads from the active tenant; reject if src doesn't match.
|
|
62
|
+
if (srcTenant !== scope.id) {
|
|
63
|
+
ctx.scrollback.push({
|
|
64
|
+
kind: 'status',
|
|
65
|
+
text: 'xfer: source must be the active project scope in v1 — switch to the source scope first',
|
|
66
|
+
level: 'error',
|
|
67
|
+
ts,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const opts = { delete: !copy, targetShardId: dstParsed.shardId };
|
|
72
|
+
if (!recursive) {
|
|
73
|
+
if (!srcParsed.path) {
|
|
74
|
+
ctx.scrollback.push({ kind: 'status', text: 'xfer: path required (use -R for folder recursion)', level: 'error', ts });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await ctx.docs.transferToScope(srcParsed.shardId, srcParsed.path, dstTenant, opts);
|
|
78
|
+
const verb = copy ? 'copied' : 'moved';
|
|
79
|
+
ctx.scrollback.push({ kind: 'status', text: `xfer: ${verb} ${positional[0]} → ${positional[1]}`, level: 'info', ts });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Recursive: list all docs in srcTenant matching the prefix
|
|
83
|
+
// transferToScope uses getTenantId() (active scope) — to read from srcTenant
|
|
84
|
+
// we rely on the src scope being the active tenant or the capability seeing it.
|
|
85
|
+
// For v1 we use listDocuments (active tenant) and filter by shard + prefix.
|
|
86
|
+
const prefix = srcParsed.path;
|
|
87
|
+
const allDocs = await ctx.docs.listDocuments();
|
|
88
|
+
const matching = allDocs.filter((d) => d.shardId === srcParsed.shardId && (!prefix || d.path.startsWith(prefix)));
|
|
89
|
+
if (matching.length === 0) {
|
|
90
|
+
ctx.scrollback.push({ kind: 'status', text: `xfer: no documents found under ${positional[0]}`, level: 'info', ts });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (const doc of matching) {
|
|
95
|
+
await ctx.docs.transferToScope(doc.shardId, doc.path, dstTenant, opts);
|
|
96
|
+
count++;
|
|
97
|
+
}
|
|
98
|
+
const verb = copy ? 'copied' : 'moved';
|
|
99
|
+
ctx.scrollback.push({ kind: 'status', text: `xfer: ${verb} ${count} document${count !== 1 ? 's' : ''}`, level: 'info', ts });
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|