@mangtre/cli 0.1.0
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/LICENSE +26 -0
- package/dist/chunk-2ZQJSRHJ.js +380 -0
- package/dist/chunk-MLWT65G7.js +17 -0
- package/dist/index.js +524 -0
- package/dist/lib.js +28 -0
- package/dist/vite.js +36 -0
- package/package.json +40 -0
- package/src/commands/args.ts +22 -0
- package/src/commands/build.ts +62 -0
- package/src/commands/check.ts +45 -0
- package/src/commands/login.ts +96 -0
- package/src/commands/new.ts +41 -0
- package/src/commands/publish.ts +118 -0
- package/src/commands/run.ts +143 -0
- package/src/commands/status.ts +70 -0
- package/src/index.ts +78 -0
- package/src/lib/api.ts +61 -0
- package/src/lib/auth-store.ts +42 -0
- package/src/lib/check.test.ts +57 -0
- package/src/lib/check.ts +49 -0
- package/src/lib/config.ts +27 -0
- package/src/lib/index.ts +12 -0
- package/src/lib/manifest-comment.ts +23 -0
- package/src/lib/scaffold.ts +56 -0
- package/src/vite.ts +73 -0
- package/templates/app/README.md.tmpl +28 -0
- package/templates/app/package.json.tmpl +30 -0
- package/templates/app/src/App.tsx.tmpl +48 -0
- package/templates/app/src/index.tsx.tmpl +40 -0
- package/templates/app/src/manifest.ts.tmpl +21 -0
- package/templates/app/src/sample.ts.tmpl +12 -0
- package/templates/app/tsconfig.json.tmpl +8 -0
- package/templates/app/vite.config.ts.tmpl +11 -0
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fetch wrapper for the platform API: prefixes the base URL, attaches a Bearer token, parses
|
|
3
|
+
* JSON, and throws a typed `ApiError` (status + server message) on a non-2xx response.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
public readonly status: number,
|
|
9
|
+
message: string,
|
|
10
|
+
) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ApiError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApiOptions {
|
|
17
|
+
base: string;
|
|
18
|
+
token?: string;
|
|
19
|
+
method?: string;
|
|
20
|
+
/** JSON body (object) OR a FormData (multipart upload — Content-Type left to fetch). */
|
|
21
|
+
body?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Call `path` on the API; returns parsed JSON (or `{}` for empty bodies). Throws `ApiError` on failure. */
|
|
25
|
+
export async function apiFetch<T = unknown>(path: string, opts: ApiOptions): Promise<T> {
|
|
26
|
+
const headers: Record<string, string> = {};
|
|
27
|
+
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
|
28
|
+
|
|
29
|
+
let body: BodyInit | undefined;
|
|
30
|
+
if (opts.body instanceof FormData) {
|
|
31
|
+
body = opts.body; // fetch sets the multipart boundary
|
|
32
|
+
} else if (opts.body !== undefined) {
|
|
33
|
+
headers["Content-Type"] = "application/json";
|
|
34
|
+
body = JSON.stringify(opts.body);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const res = await fetch(`${opts.base}${path}`, {
|
|
38
|
+
method: opts.method ?? (body ? "POST" : "GET"),
|
|
39
|
+
headers,
|
|
40
|
+
body,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
const data = text ? safeJson(text) : {};
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
let message = `HTTP ${res.status}`;
|
|
47
|
+
if (data && typeof data === "object" && "error" in data) {
|
|
48
|
+
message = String((data as { error: unknown }).error);
|
|
49
|
+
}
|
|
50
|
+
throw new ApiError(res.status, message);
|
|
51
|
+
}
|
|
52
|
+
return data as T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeJson(text: string): unknown {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return { raw: text };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local credential store (`~/.mang/auth.json`). Holds the opaque Bearer token + the API base it was
|
|
3
|
+
* minted against. Written `0600` (owner-only) — it's a real credential, treat it like an SSH key.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { authFilePath } from "./config";
|
|
9
|
+
|
|
10
|
+
export interface StoredAuth {
|
|
11
|
+
token: string;
|
|
12
|
+
api: string;
|
|
13
|
+
handle?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Load the stored credential, or null if absent/unreadable. */
|
|
17
|
+
export function loadAuth(): StoredAuth | null {
|
|
18
|
+
const path = authFilePath();
|
|
19
|
+
if (!existsSync(path)) return null;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
22
|
+
if (parsed && typeof parsed.token === "string" && typeof parsed.api === "string") {
|
|
23
|
+
return parsed as StoredAuth;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// fall through
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Persist the credential with owner-only permissions. */
|
|
32
|
+
export function saveAuth(auth: StoredAuth): void {
|
|
33
|
+
const path = authFilePath();
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
35
|
+
writeFileSync(path, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Remove the stored credential (logout). */
|
|
39
|
+
export function clearAuth(): void {
|
|
40
|
+
const path = authFilePath();
|
|
41
|
+
if (existsSync(path)) rmSync(path);
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { checkBundle } from "./check";
|
|
3
|
+
import { MANIFEST_COMMENT_PREFIX } from "./manifest-comment";
|
|
4
|
+
|
|
5
|
+
const NOW = 1_700_000_000_000;
|
|
6
|
+
|
|
7
|
+
const goodManifest = {
|
|
8
|
+
id: "demo-moc",
|
|
9
|
+
name: "Demo Mọc",
|
|
10
|
+
icon: "🌱",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
permissions: ["storage", "theme"],
|
|
13
|
+
flow: [{ id: "home", title: { vi: "Trang chính", en: "Home" }, checkpoint: "home" }],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function bundle(manifest: unknown, body = "export const mount = () => () => {};"): string {
|
|
17
|
+
return `${MANIFEST_COMMENT_PREFIX}${JSON.stringify(manifest)}\n${body}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("checkBundle", () => {
|
|
21
|
+
it("passes a valid bundle (manifest from the comment, safe body, declared flow)", () => {
|
|
22
|
+
const result = checkBundle({ source: bundle(goodManifest), now: NOW });
|
|
23
|
+
expect(result.manifestFound).toBe(true);
|
|
24
|
+
expect(result.passed).toBe(true);
|
|
25
|
+
expect(result.findings.some((f) => f.severity === "fail")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("fails on a malformed manifest (bad slug) with the server's exact check id", () => {
|
|
29
|
+
const result = checkBundle({ source: bundle({ ...goodManifest, id: "X" }), now: NOW });
|
|
30
|
+
expect(result.passed).toBe(false);
|
|
31
|
+
expect(
|
|
32
|
+
result.findings.some((f) => f.check === "manifest.schema" && f.severity === "fail"),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("fails on a banned API in the body (safety scan)", () => {
|
|
37
|
+
const result = checkBundle({
|
|
38
|
+
source: bundle(goodManifest, "const x = eval('1+1');"),
|
|
39
|
+
now: NOW,
|
|
40
|
+
});
|
|
41
|
+
expect(result.passed).toBe(false);
|
|
42
|
+
expect(result.findings.some((f) => f.check === "safety.eval")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports manifestFound=false when there is no manifest comment", () => {
|
|
46
|
+
const result = checkBundle({ source: "export const mount = () => () => {};", now: NOW });
|
|
47
|
+
expect(result.manifestFound).toBe(false);
|
|
48
|
+
expect(result.passed).toBe(false); // empty manifest fails schema
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("flags an ownership mismatch when --app differs from manifest.id", () => {
|
|
52
|
+
const result = checkBundle({ source: bundle(goodManifest), appId: "other-app", now: NOW });
|
|
53
|
+
expect(result.findings.some((f) => f.check === "manifest.slug" && f.severity === "fail")).toBe(
|
|
54
|
+
true,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/lib/check.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline preflight — the local mirror of the server's "chuẩn Măng" gate. Runs the EXACT same
|
|
3
|
+
* `@mangtre/standard.validateBundle` the Worker Queue runs, so a creator (or their coding agent) sees
|
|
4
|
+
* the same fail/warn findings BEFORE uploading — "đừng đợi server". The server stays authoritative;
|
|
5
|
+
* this is the fast feedback loop.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type ValidationReport, validateBundle } from "@mangtre/standard";
|
|
9
|
+
import { extractManifestComment } from "./manifest-comment";
|
|
10
|
+
|
|
11
|
+
export interface CheckOptions {
|
|
12
|
+
/** The built bundle's full source text. */
|
|
13
|
+
source: string;
|
|
14
|
+
/** The manifest object; if omitted, read from the bundle's `// mang-manifest:` comment. */
|
|
15
|
+
manifest?: unknown;
|
|
16
|
+
/** The app id the upload would claim; defaults to `manifest.id` (override to test ownership). */
|
|
17
|
+
appId?: string;
|
|
18
|
+
/** Epoch ms (injectable for tests). */
|
|
19
|
+
now?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Result of a local check: the standard's report plus whether a manifest was found at all. */
|
|
23
|
+
export interface CheckResult extends ValidationReport {
|
|
24
|
+
/** False when no manifest was supplied or extractable from the bundle comment. */
|
|
25
|
+
manifestFound: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function manifestId(manifest: unknown): string | undefined {
|
|
29
|
+
if (typeof manifest === "object" && manifest !== null && !Array.isArray(manifest)) {
|
|
30
|
+
const id = (manifest as Record<string, unknown>).id;
|
|
31
|
+
if (typeof id === "string") return id;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Run the shared validator over a built bundle. Never throws. */
|
|
37
|
+
export function checkBundle(opts: CheckOptions): CheckResult {
|
|
38
|
+
const manifest = opts.manifest ?? extractManifestComment(opts.source);
|
|
39
|
+
const manifestFound = manifest !== undefined;
|
|
40
|
+
const appId = opts.appId ?? manifestId(manifest) ?? "";
|
|
41
|
+
const report = validateBundle({
|
|
42
|
+
source: opts.source,
|
|
43
|
+
bytes: Buffer.byteLength(opts.source, "utf8"),
|
|
44
|
+
appId,
|
|
45
|
+
manifest: manifest ?? {},
|
|
46
|
+
now: opts.now ?? Date.now(),
|
|
47
|
+
});
|
|
48
|
+
return { ...report, manifestFound };
|
|
49
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI config: where the platform API lives + where the local auth token is stored.
|
|
3
|
+
* API base resolution order: `--api <url>` flag → `MANG_API` env → production default.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { getFlag } from "../commands/args";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_API_BASE = "https://api.mang.rumitx.com";
|
|
11
|
+
|
|
12
|
+
/** Normalize a user-supplied base: trim, drop trailing slash, and add `https://` if no scheme is
|
|
13
|
+
* given (so `--api api-dev.mang.rumitx.com` works, not just `--api https://…`). */
|
|
14
|
+
export function normalizeApiBase(raw: string): string {
|
|
15
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
16
|
+
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve the platform API base URL from argv/env. */
|
|
20
|
+
export function resolveApiBase(argv: string[]): string {
|
|
21
|
+
return normalizeApiBase(getFlag(argv, "api") ?? process.env.MANG_API ?? DEFAULT_API_BASE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Path to the stored credential (`~/.mang/auth.json`). */
|
|
25
|
+
export function authFilePath(): string {
|
|
26
|
+
return join(homedir(), ".mang", "auth.json");
|
|
27
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@mangtre/cli/lib` — the reusable core of the Mọc CLI (no process/argv/console), so other harnesses —
|
|
3
|
+
* the `@mangtre/mcp` server, future adapters — drive the SAME logic the `mang` binary does. The CLI
|
|
4
|
+
* commands (`src/commands/*`) are thin presentation wrappers over these.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { ApiError, type ApiOptions, apiFetch } from "./api";
|
|
8
|
+
export { type StoredAuth, clearAuth, loadAuth, saveAuth } from "./auth-store";
|
|
9
|
+
export { type CheckOptions, type CheckResult, checkBundle } from "./check";
|
|
10
|
+
export { DEFAULT_API_BASE, authFilePath } from "./config";
|
|
11
|
+
export { MANIFEST_COMMENT_PREFIX, extractManifestComment } from "./manifest-comment";
|
|
12
|
+
export { type ScaffoldOptions, scaffoldApp } from "./scaffold";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `// mang-manifest:<json>` first-line comment a Mọc-built bundle carries — the convention the
|
|
3
|
+
* creator dashboard reads to pre-fill a publish, and the CLI reads to preflight a built bundle
|
|
4
|
+
* offline. Emitted by `mangLibConfig` (`../vite.ts`) as a rollup output banner.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Prefix the build emits on line 1 of the bundle, followed by the manifest JSON. */
|
|
8
|
+
export const MANIFEST_COMMENT_PREFIX = "// mang-manifest:";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pull the manifest object out of a built bundle's first-line comment, or `undefined` if absent /
|
|
12
|
+
* unparseable. Only inspects the first line — the banner always sits at the very top.
|
|
13
|
+
*/
|
|
14
|
+
export function extractManifestComment(source: string): unknown | undefined {
|
|
15
|
+
const firstLine = source.split("\n", 1)[0] ?? "";
|
|
16
|
+
const match = firstLine.match(/^\/\/\s*mang-manifest:\s*(\{.*\})\s*$/);
|
|
17
|
+
if (!match?.[1]) return undefined;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(match[1]);
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolding: copy the `templates/app` tree into a target dir, replacing `__SLUG__`/`__NAME__`/
|
|
3
|
+
* `__ICON__` placeholders and stripping the `.tmpl` suffix. Pure-ish (fs side effects only).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
/** Absolute path to the bundled `templates/app` dir (resolved relative to the built bin in dist/). */
|
|
12
|
+
function templateRoot(): string {
|
|
13
|
+
return fileURLToPath(new URL("../templates/app", import.meta.url));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ScaffoldOptions {
|
|
17
|
+
slug: string;
|
|
18
|
+
name: string;
|
|
19
|
+
icon: string;
|
|
20
|
+
/** Target directory to create (must not already exist). */
|
|
21
|
+
targetDir: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function applyPlaceholders(text: string, opts: ScaffoldOptions): string {
|
|
25
|
+
return text
|
|
26
|
+
.replaceAll("__SLUG__", opts.slug)
|
|
27
|
+
.replaceAll("__NAME__", opts.name)
|
|
28
|
+
.replaceAll("__ICON__", opts.icon);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Recursively copy + template every file under `dir` into `dest`. */
|
|
32
|
+
async function copyTree(srcDir: string, destDir: string, opts: ScaffoldOptions): Promise<string[]> {
|
|
33
|
+
await mkdir(destDir, { recursive: true });
|
|
34
|
+
const written: string[] = [];
|
|
35
|
+
for (const entry of await readdir(srcDir, { withFileTypes: true })) {
|
|
36
|
+
const srcPath = join(srcDir, entry.name);
|
|
37
|
+
const outName = entry.name.endsWith(".tmpl") ? entry.name.slice(0, -5) : entry.name;
|
|
38
|
+
const destPath = join(destDir, outName);
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
written.push(...(await copyTree(srcPath, destPath, opts)));
|
|
41
|
+
} else {
|
|
42
|
+
const raw = await readFile(srcPath, "utf8");
|
|
43
|
+
await writeFile(destPath, applyPlaceholders(raw, opts), "utf8");
|
|
44
|
+
written.push(destPath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return written;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Create a new standard-compliant mini-app from the template. Returns the files written. */
|
|
51
|
+
export async function scaffoldApp(opts: ScaffoldOptions): Promise<string[]> {
|
|
52
|
+
if (existsSync(opts.targetDir)) {
|
|
53
|
+
throw new Error(`Thư mục đã tồn tại: ${opts.targetDir}`);
|
|
54
|
+
}
|
|
55
|
+
return copyTree(templateRoot(), opts.targetDir, opts);
|
|
56
|
+
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@mangtre/cli/vite` — the Vite library-mode preset a scaffolded mini-app imports in its `vite.config`.
|
|
3
|
+
* It produces the ONE self-contained ESM file the platform expects (`{ manifest, mount }`) and stamps
|
|
4
|
+
* the `// mang-manifest:<json>` first-line comment the dashboard + `mang check` read. This is the
|
|
5
|
+
* "how do I build to a single bundle" piece creators otherwise hand-roll.
|
|
6
|
+
*
|
|
7
|
+
* Returns a plain config object (no runtime Vite dependency here); the app merges it with its React
|
|
8
|
+
* plugin via Vite's `mergeConfig`. The manifest comment is injected by a small `generateBundle` plugin
|
|
9
|
+
* (more reliable than a rollup `banner`, which lib mode + other plugins can clobber).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { MANIFEST_COMMENT_PREFIX } from "./lib/manifest-comment";
|
|
13
|
+
|
|
14
|
+
export interface MangLibConfigOptions {
|
|
15
|
+
/** The app's manifest (import it from `./src/manifest`); only `id` is required for the banner. */
|
|
16
|
+
manifest: { id: string; [key: string]: unknown };
|
|
17
|
+
/** Entry module; defaults to `src/index.tsx`. */
|
|
18
|
+
entry?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Minimal structural view of a rollup output chunk (avoids a Vite/rollup type dependency here). */
|
|
22
|
+
interface OutputChunk {
|
|
23
|
+
type: string;
|
|
24
|
+
isEntry?: boolean;
|
|
25
|
+
code?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A minimal structural subset of a Vite config — enough for `mergeConfig` to accept it. */
|
|
29
|
+
export interface MangLibConfig {
|
|
30
|
+
build: {
|
|
31
|
+
target: string;
|
|
32
|
+
emptyOutDir: boolean;
|
|
33
|
+
lib: { entry: string; formats: ["es"]; fileName: () => string };
|
|
34
|
+
};
|
|
35
|
+
/** Compile-time replacements. CRITICAL: Vite library mode does NOT replace `process.env.NODE_ENV`,
|
|
36
|
+
* so React's `process.env.NODE_ENV` reference reaches the browser sandbox (no `process` global) and
|
|
37
|
+
* throws "process is not defined". Replacing it at build time fixes that for every Mọc app. */
|
|
38
|
+
define: Record<string, string>;
|
|
39
|
+
plugins: unknown[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build the lib-mode config for a Mọc mini-app. */
|
|
43
|
+
export function mangLibConfig(opts: MangLibConfigOptions): MangLibConfig {
|
|
44
|
+
const entry = opts.entry ?? "src/index.tsx";
|
|
45
|
+
const banner = `${MANIFEST_COMMENT_PREFIX}${JSON.stringify(opts.manifest)}`;
|
|
46
|
+
|
|
47
|
+
// Prepend the manifest comment to the entry chunk so it's always line 1 — the platform indexes it
|
|
48
|
+
// without executing the bundle.
|
|
49
|
+
const manifestBannerPlugin = {
|
|
50
|
+
name: "mang:manifest-banner",
|
|
51
|
+
generateBundle(_options: unknown, bundle: Record<string, OutputChunk>) {
|
|
52
|
+
for (const file of Object.values(bundle)) {
|
|
53
|
+
if (file.type === "chunk" && file.isEntry && typeof file.code === "string") {
|
|
54
|
+
file.code = `${banner}\n${file.code}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
build: {
|
|
62
|
+
target: "es2022",
|
|
63
|
+
emptyOutDir: true,
|
|
64
|
+
lib: { entry, formats: ["es"], fileName: () => "app.js" },
|
|
65
|
+
},
|
|
66
|
+
// The mini-app runs in a browser sandbox with no `process`; bake the prod NODE_ENV in so React
|
|
67
|
+
// (and other libs that branch on it) don't reference a missing `process` global at runtime.
|
|
68
|
+
define: {
|
|
69
|
+
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
70
|
+
},
|
|
71
|
+
plugins: [manifestBannerPlugin],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# __ICON__ __NAME__
|
|
2
|
+
|
|
3
|
+
A Măng mini-app, scaffolded with **Mọc** (`mang new`).
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm install
|
|
9
|
+
mang build # build → dist/app.js, then preflight against "chuẩn Măng"
|
|
10
|
+
mang check # re-run the offline preflight on the built bundle
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What's here
|
|
14
|
+
|
|
15
|
+
- `src/manifest.ts` — your app's manifest (id, permissions, **commands**, **flow/luồng chính**).
|
|
16
|
+
The platform indexes this without loading your code.
|
|
17
|
+
- `src/index.tsx` — the `mount(root, sdk) => unmount` entry. Talk to the outside world ONLY via
|
|
18
|
+
`sdk.*` (storage, theme, …) — that's the golden rule.
|
|
19
|
+
- `src/App.tsx` — your UI. Renders `data-mang-checkpoint="home"` so the platform can walk your main
|
|
20
|
+
flow + capture real screenshots. Built with `@mangtre/ui` (auto light/dark + brand thread).
|
|
21
|
+
- `vite.config.ts` — builds the single self-contained ESM bundle (`mangLibConfig`).
|
|
22
|
+
|
|
23
|
+
## Rules the preflight enforces ("chuẩn Măng")
|
|
24
|
+
|
|
25
|
+
- ≤ 2 MB bundle · valid manifest · a declared **flow** · desktop-responsive (verify at 1440px + 390px).
|
|
26
|
+
- No `eval` / `new Function` / remote `import()` / `WebSocket`. Reach the network/storage only via `sdk.*`.
|
|
27
|
+
|
|
28
|
+
See the creator guide (`?guide=1`) for the full standard.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mangtre/__SLUG__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.tsx",
|
|
7
|
+
"types": "./src/index.tsx",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.tsx",
|
|
10
|
+
"./manifest": "./src/manifest.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "mang build",
|
|
14
|
+
"check": "mang check",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@mangtre/core": "^0.1.0",
|
|
19
|
+
"@mangtre/ui": "^0.1.0",
|
|
20
|
+
"react": "^18.3.1",
|
|
21
|
+
"react-dom": "^18.3.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@mangtre/cli": "^0.1.0",
|
|
25
|
+
"@types/react": "^18.3.12",
|
|
26
|
+
"@types/react-dom": "^18.3.1",
|
|
27
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
28
|
+
"vite": "^6.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ScopedStorage } from "@mangtre/core";
|
|
2
|
+
import { Button, Card, Heading, Stack, Text } from "@mangtre/ui";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { COUNT_KEY } from "./sample";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The app's single screen. Renders `data-mang-checkpoint="home"` so the platform's main-flow walk
|
|
8
|
+
* can find it. Layout caps its width + centres on desktop (Design System §9: fill the card, no dead
|
|
9
|
+
* mobile column) — verify at 1440px AND 390px.
|
|
10
|
+
*/
|
|
11
|
+
export function App({ storage }: { storage: ScopedStorage }) {
|
|
12
|
+
const [count, setCount] = useState(0);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
storage.get<number>(COUNT_KEY).then((v) => setCount(v ?? 0));
|
|
16
|
+
}, [storage]);
|
|
17
|
+
|
|
18
|
+
const bump = async () => {
|
|
19
|
+
const next = count + 1;
|
|
20
|
+
setCount(next); // optimistic
|
|
21
|
+
await storage.set(COUNT_KEY, next);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-mang-checkpoint="home"
|
|
27
|
+
style={{ maxWidth: 1040, margin: "0 auto", padding: "clamp(1rem, 3vw, 2rem)" }}
|
|
28
|
+
>
|
|
29
|
+
<Stack gap={5}>
|
|
30
|
+
<Heading level={1}>__ICON__ __NAME__</Heading>
|
|
31
|
+
<Text tone="muted">
|
|
32
|
+
Mini-app khởi tạo bằng Mọc. Sửa <code>src/App.tsx</code> để bắt đầu. Dữ liệu lưu qua{" "}
|
|
33
|
+
<code>sdk.storage</code> (cục bộ, theo máy).
|
|
34
|
+
</Text>
|
|
35
|
+
<Card style={{ padding: "1.25rem" }}>
|
|
36
|
+
<Stack gap={4}>
|
|
37
|
+
<Text>
|
|
38
|
+
Đã bấm <strong>{count}</strong> lần.
|
|
39
|
+
</Text>
|
|
40
|
+
<div>
|
|
41
|
+
<Button onClick={bump}>Bấm +1</Button>
|
|
42
|
+
</div>
|
|
43
|
+
</Stack>
|
|
44
|
+
</Card>
|
|
45
|
+
</Stack>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __NAME__ — a Măng mini-app.
|
|
3
|
+
*
|
|
4
|
+
* The contract is `Mount = (root, sdk) => Unmount`: spin up a React root inside `root`, talk to the
|
|
5
|
+
* outside world ONLY through `sdk.*` (storage/theme/…), and tear down on unmount. `@mangtre/ui`'s
|
|
6
|
+
* `MangApp` wires the theme (light/dark) + locale for you.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Mount, PrepareDemo } from "@mangtre/core";
|
|
10
|
+
import { MangApp } from "@mangtre/ui";
|
|
11
|
+
import { StrictMode } from "react";
|
|
12
|
+
import { createRoot } from "react-dom/client";
|
|
13
|
+
import { App } from "./App";
|
|
14
|
+
import { seedSample } from "./sample";
|
|
15
|
+
|
|
16
|
+
// Re-export the data-only manifest so this module satisfies the MiniAppModule contract.
|
|
17
|
+
export { manifest } from "./manifest";
|
|
18
|
+
|
|
19
|
+
export const prepareDemo: PrepareDemo = async (sdk) => {
|
|
20
|
+
await seedSample(sdk.storage);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const mount: Mount = (root, sdk) => {
|
|
24
|
+
const container = document.createElement("div");
|
|
25
|
+
root.appendChild(container);
|
|
26
|
+
|
|
27
|
+
const reactRoot = createRoot(container);
|
|
28
|
+
reactRoot.render(
|
|
29
|
+
<StrictMode>
|
|
30
|
+
<MangApp locale={sdk.locale} sdkTheme={sdk.theme}>
|
|
31
|
+
<App storage={sdk.storage} />
|
|
32
|
+
</MangApp>
|
|
33
|
+
</StrictMode>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
reactRoot.unmount();
|
|
38
|
+
container.remove();
|
|
39
|
+
};
|
|
40
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MangManifest } from "@mangtre/core";
|
|
2
|
+
|
|
3
|
+
// Data-only manifest — the platform indexes this WITHOUT loading your app's runtime.
|
|
4
|
+
// `flow` is the "luồng chính" the platform walks to prove your screens render + capture the gallery;
|
|
5
|
+
// each step's `checkpoint` must match a `data-mang-checkpoint="…"` element you render.
|
|
6
|
+
export const manifest: MangManifest = {
|
|
7
|
+
id: "__SLUG__",
|
|
8
|
+
name: "__NAME__",
|
|
9
|
+
icon: "__ICON__",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
permissions: ["storage", "theme"],
|
|
12
|
+
commands: [{ id: "home", title: { vi: "Trang chính", en: "Home" }, icon: "__ICON__" }],
|
|
13
|
+
flow: [
|
|
14
|
+
{
|
|
15
|
+
id: "home",
|
|
16
|
+
title: { vi: "Trang chính", en: "Home" },
|
|
17
|
+
checkpoint: "home",
|
|
18
|
+
caption: { vi: "Màn hình chính của app", en: "The app's main screen" },
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ScopedStorage } from "@mangtre/core";
|
|
2
|
+
|
|
3
|
+
export const COUNT_KEY = "count";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Seed demo state so the platform's main-flow walk captures a populated screen (not an empty one).
|
|
7
|
+
* Idempotent — runs once via `prepareDemo`, leaves existing data untouched.
|
|
8
|
+
*/
|
|
9
|
+
export async function seedSample(storage: ScopedStorage): Promise<void> {
|
|
10
|
+
const existing = await storage.get<number>(COUNT_KEY);
|
|
11
|
+
if (existing == null) await storage.set(COUNT_KEY, 3);
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { mangLibConfig } from "@mangtre/cli/vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import { defineConfig, mergeConfig } from "vite";
|
|
4
|
+
import { manifest } from "./src/manifest";
|
|
5
|
+
|
|
6
|
+
// `mangLibConfig` produces the single-ESM lib build + the `// mang-manifest:` banner the platform
|
|
7
|
+
// reads. We merge in the React plugin. The output is `dist/app.js`.
|
|
8
|
+
export default mergeConfig(
|
|
9
|
+
mangLibConfig({ manifest }),
|
|
10
|
+
defineConfig({ plugins: [react()] }),
|
|
11
|
+
);
|