// The ziran CLI, end to end as a real subprocess against the fake network:
// login/logout/accounts/use, publish from .tile AND from unpacked sources,
// list, version chain, unpublish.
// Run: deno run -A src/cli/cli.test.ts
import { join } from 'jsr:@std/path@1';
import { FakeSpacesNetwork } from '../sync/spaces/fake.ts';
import { getPublished } from '../store/store.ts';
import { writeTile } from '../server/tilefile.ts';
let failures = 0;
const check = (label: string, ok: boolean, detail = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
if (!ok) failures++;
};
const net = new FakeSpacesNetwork();
const alice = net.account('alice');
const bob = net.account('bob');
const dir = await Deno.makeTempDir({ prefix: 'ziran-cli-' });
const configDir = join(dir, 'config');
// A packed tile on disk.
const tilePath = join(dir, 'My Notes.tile');
await writeTile(
{
name: 'Untitled notes',
description: 'Notes for testing.',
model: { id: 'com.example.notes', name: 'Notes' },
resources: {},
},
new Map([['/', new TextEncoder().encode('
n')]]),
tilePath,
);
// An UNPACKED tile: a directory with manifest.json + index.html + an asset.
const unpacked = join(dir, 'Party Game');
await Deno.mkdir(unpacked);
await Deno.writeTextFile(
join(unpacked, 'manifest.json'),
JSON.stringify({ name: 'Untitled party', description: 'A game.', resources: {} }),
);
await Deno.writeTextFile(join(unpacked, 'index.html'), 'p');
await Deno.writeTextFile(join(unpacked, 'style.css'), 'body { margin: 0 }');
const CLI = new URL('./ziran.ts', import.meta.url).pathname;
async function ziran(args: string[], env: Record = {}): Promise<{ code: number; out: string; err: string }> {
const cmd = await new Deno.Command(Deno.execPath(), {
args: ['run', '-A', CLI, ...args],
// ZIRAN_NO_ENV_CREDS: the test must NEVER fall through to ambient
// credentials (a real .env.store-publisher sits at the repo root).
env: { ZIRAN_CONFIG_DIR: configDir, ZIRAN_NO_ENV_CREDS: '1', ...env },
stdout: 'piped',
stderr: 'piped',
}).output();
return {
code: cmd.code,
out: new TextDecoder().decode(cmd.stdout),
err: new TextDecoder().decode(cmd.stderr),
};
}
// The legacy-chain test provides a COMPLETE env identity (so no file is
// ever consulted) and re-enables the chain.
const legacyEnv = {
ZIRAN_HANDLE: alice.handle,
ZIRAN_PASSWORD: alice.password,
ZIRAN_PDS: net.url,
ZIRAN_NO_ENV_CREDS: '',
};
/* ——— help & refusals ——— */
const help = await ziran(['help']);
check('help prints usage', help.code === 0 && help.out.includes('publish '), help.err);
const unknown = await ziran(['frobnicate']);
check('an unknown command is refused with guidance', unknown.code === 1 && unknown.err.includes('ziran help'));
const noone = await ziran(['publish', tilePath]);
check('publishing signed out says how to sign in', noone.code === 1 && noone.err.includes('ziran login'), noone.err);
/* ——— login / accounts / use / logout ——— */
const badPw = await ziran(['login', alice.handle, '--password', 'wrong', '--pds', net.url]);
check('login verifies before storing (bad password fails)', badPw.code === 1, badPw.out);
const inA = await ziran(['login', alice.handle, '--password', alice.password, '--pds', net.url]);
check('login signs in and stores', inA.code === 0 && inA.out.includes(`signed in as ${alice.handle}`), inA.err);
check('the first login is the default', inA.out.includes('default'));
await ziran(['login', bob.handle, '--password', bob.password, '--pds', net.url]);
const who = await ziran(['accounts']);
check(
'accounts lists everyone, * marks the default',
who.out.includes(alice.handle) && who.out.includes(bob.handle) &&
who.out.split('\n').some((l) => l.startsWith('*') && l.includes(alice.handle)),
who.out,
);
const mode = (await Deno.stat(join(configDir, 'accounts.json'))).mode;
check('the accounts file is owner-only', mode !== null && (mode & 0o077) === 0, String(mode! & 0o777));
/* ——— publish: default account, --as, sources ——— */
const pub = await ziran(['publish', tilePath]);
check('publish uses the default account', pub.code === 0 && pub.out.includes(`@${alice.handle}/my-notes`), pub.err);
check('…reports the version cid', /version: baf/.test(pub.out), pub.out);
check('the record is really on the server', (await getPublished(fetch, net.url, alice.did, 'my-notes'))?.value.title === 'Notes');
const asBob = await ziran(['publish', tilePath, '--as', bob.handle, '--name', 'notes-b']);
check('--as picks another signed-in account', asBob.code === 0 && asBob.out.includes(`@${bob.handle}/notes-b`), asBob.err);
check('…and lands in THEIR repo', (await getPublished(fetch, net.url, bob.did, 'notes-b')) !== null);
const fromDir = await ziran(['publish', unpacked]);
check('publish packs an unpacked-tile directory', fromDir.code === 0 && fromDir.out.includes(`@${alice.handle}/party-game`), fromDir.err);
const dirPub = await getPublished(fetch, net.url, alice.did, 'party-game');
check('…as an app (no model field)', dirPub?.value.kind === 'app');
check('…with its resources aboard', Boolean(dirPub?.value.tile.resources['/style.css']), JSON.stringify(Object.keys(dirPub?.value.tile.resources ?? {})));
const fromManifest = await ziran(['publish', join(unpacked, 'manifest.json'), '--name', 'party-two']);
check('a manifest.json path publishes its directory', fromManifest.code === 0 && fromManifest.out.includes('party-two'), fromManifest.err);
/* ——— use, list, legacy env, version chain, unpublish ——— */
await ziran(['use', bob.handle]);
const who2 = await ziran(['accounts']);
check('use switches the default', who2.out.split('\n').some((l) => l.startsWith('*') && l.includes(bob.handle)), who2.out);
const listed = await ziran(['list', alice.did, '--pds', net.url]);
check('list shows publishes', listed.code === 0 && listed.out.includes('my-notes'), listed.out || listed.err);
const legacy = await ziran(['publish', tilePath, '--name', 'legacy-envs'], { ...legacyEnv, ZIRAN_CONFIG_DIR: join(dir, 'empty-config') });
check('the env credential chain still works when nobody is signed in', legacy.code === 0 && legacy.out.includes(alice.handle), legacy.err);
await writeTile(
{
name: 'Untitled notes',
description: 'Better notes.',
model: { id: 'com.example.notes', name: 'Notes' },
resources: {},
},
new Map([['/', new TextEncoder().encode('n2')]]),
tilePath,
);
const pub2 = await ziran(['publish', tilePath, '--name', 'my-notes', '--as', alice.handle]);
check('re-publishing a changed tile chains a version', pub2.code === 0 && pub2.out.includes('replaces baf'), pub2.out || pub2.err);
const gone = await ziran(['unpublish', 'my-notes', '--as', alice.handle]);
check('unpublish succeeds', gone.code === 0, gone.err);
check('the record is really gone', (await getPublished(fetch, net.url, alice.did, 'my-notes')) === null);
const outB = await ziran(['logout', bob.handle]);
check('logout removes the account and moves the default', outB.code === 0 && outB.out.includes(`default is now ${alice.handle}`), outB.out);
const who3 = await ziran(['accounts']);
check('the signed-out account is gone', !who3.out.includes(bob.handle), who3.out);
await Deno.remove(dir, { recursive: true });
await net.close();
console.log(failures ? `\n${failures} FAILED` : '\nall green');
Deno.exit(failures ? 1 : 0);