@tinycld/bootstrap 1.1.0 → 2.0.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/README.md +82 -38
- package/dist/args.js +16 -3
- package/dist/args.js.map +1 -1
- package/dist/assemble-workspace.js +175 -0
- package/dist/assemble-workspace.js.map +1 -0
- package/dist/index.js +71 -8
- package/dist/index.js.map +1 -1
- package/dist/link-package.js +10 -9
- package/dist/link-package.js.map +1 -1
- package/package.json +3 -2
- package/templates/shared/.github/workflows/ci.yml +23 -25
- package/templates/shared/README.md +9 -8
- package/templates/workspace/.go-version +1 -0
- package/templates/workspace/.node-version +1 -0
- package/templates/workspace/tests/expo-clipboard-stub.ts +20 -0
- package/templates/workspace/tests/expo-router-stub.ts +48 -0
- package/templates/workspace/tests/lucide-react-native-stub.cjs +73 -0
- package/templates/workspace/tests/tinycld.packages.test.ts +54 -0
- package/templates/workspace/tests/unit-setup.ts +55 -0
- package/templates/workspace/tinycld.packages.ts +75 -0
- package/templates/workspace/vitest.config.ts +104 -0
- package/dist/bootstrap-tooling.js +0 -149
- package/dist/bootstrap-tooling.js.map +0 -1
- package/dist/link-core.js +0 -99
- package/dist/link-core.js.map +0 -1
package/dist/link-package.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { confirm, isCancel, log, spinner } from '@clack/prompts';
|
|
5
5
|
import pc from 'picocolors';
|
|
6
|
-
import {
|
|
6
|
+
import { assembleWorkspace } from "./assemble-workspace.js";
|
|
7
7
|
import { looksLikeWorkspaceRoot } from "./layout.js";
|
|
8
8
|
/**
|
|
9
9
|
* Add `slug` to `<workspaceDir>/package.json` `workspaces[]` if it isn't already
|
|
@@ -29,14 +29,15 @@ export function ensureMember(workspaceDir, slug) {
|
|
|
29
29
|
* symlink and the root `postinstall` runs the generator.
|
|
30
30
|
*
|
|
31
31
|
* Two shapes, distinguished by whether `workspaceDir` is already a workspace root:
|
|
32
|
-
* - attach: `workspaceDir` already
|
|
32
|
+
* - attach: `workspaceDir` is already an assembled workspace root (its
|
|
33
33
|
* package.json name is `@tinycld/workspace`). No assembly — just ensure the
|
|
34
34
|
* member and install. The existing root package.json is left untouched.
|
|
35
35
|
* - bootstrap: `workspaceDir` is a fresh wrapper that only contains the
|
|
36
|
-
* scaffolded `<slug>/` so far. Assemble the workspace *around* it —
|
|
37
|
-
*
|
|
38
|
-
* ensure the new member and install. The
|
|
39
|
-
* succeeds once `app/` exists, which is
|
|
36
|
+
* scaffolded `<slug>/` so far. Assemble the workspace *around* it —
|
|
37
|
+
* `assembleWorkspace` generates the root scaffolding and clones app + core
|
|
38
|
+
* (no workspace meta-repo) — then ensure the new member and install. The
|
|
39
|
+
* `postinstall` (`cd app && …`) only succeeds once `app/` exists, which is
|
|
40
|
+
* why we assemble before installing.
|
|
40
41
|
*
|
|
41
42
|
* Returns true if the user chose to link (even if a subprocess failed — the
|
|
42
43
|
* error is already logged, and we don't want to follow up with manual steps
|
|
@@ -50,7 +51,7 @@ export async function offerLinkPackage({ slug, workspaceDir, mode, assemble = re
|
|
|
50
51
|
const needsClone = !looksLikeWorkspaceRoot(workspaceDir);
|
|
51
52
|
if (mode === 'prompt') {
|
|
52
53
|
const message = needsClone
|
|
53
|
-
? `
|
|
54
|
+
? `Assemble the tinycld workspace (app + core) and link ${pc.bold(slug)} now?`
|
|
54
55
|
: `Link ${pc.bold(slug)} into the workspace now?`;
|
|
55
56
|
const answer = await confirm({ message, initialValue: true });
|
|
56
57
|
if (isCancel(answer) || answer !== true)
|
|
@@ -83,11 +84,11 @@ export async function offerLinkPackage({ slug, workspaceDir, mode, assemble = re
|
|
|
83
84
|
/**
|
|
84
85
|
* Assemble the workspace skeleton at `dir`: write the workspace manifest and
|
|
85
86
|
* clone app + core (honoring `TINYCLD_REPO_BASE` for keyless/HTTPS envs).
|
|
86
|
-
* `
|
|
87
|
+
* `assembleWorkspace` is synchronous and throws on failure (unknown member, etc.);
|
|
87
88
|
* `offerLinkPackage` wraps the call so the error surfaces in the spinner.
|
|
88
89
|
*/
|
|
89
90
|
function realAssemble(dir) {
|
|
90
|
-
|
|
91
|
+
assembleWorkspace({ root: dir, repoBase: process.env.TINYCLD_REPO_BASE });
|
|
91
92
|
}
|
|
92
93
|
function realInstall(cwd) {
|
|
93
94
|
const r = spawnSync('npm', ['install'], { cwd, stdio: 'pipe', encoding: 'utf8' });
|
package/dist/link-package.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"link-package.js","sourceRoot":"","sources":["../src/link-package.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AACjE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAChE,OAAO,EAAE,MAAM,YAAY,CAAA;AAC3B,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"link-package.js","sourceRoot":"","sources":["../src/link-package.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinycld/bootstrap",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Scaffolder for @tinycld feature packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"
|
|
22
|
+
"clean": "rm -rf dist",
|
|
23
|
+
"build": "npm run clean && tsc",
|
|
23
24
|
"dev": "tsx src/index.ts",
|
|
24
25
|
"lint": "biome check .",
|
|
25
26
|
"lint:fix": "biome check --write .",
|
|
@@ -18,30 +18,29 @@ jobs:
|
|
|
18
18
|
name: Typecheck & Unit
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
steps:
|
|
21
|
-
# 1.
|
|
22
|
-
- uses: actions/checkout@v5
|
|
23
|
-
with:
|
|
24
|
-
repository: tinycld/workspace
|
|
25
|
-
ref: main
|
|
26
|
-
path: ws
|
|
27
|
-
# 2. This PR's code, checked out into the member slot.
|
|
21
|
+
# 1. This PR's code, checked out into the member slot.
|
|
28
22
|
- uses: actions/checkout@v5
|
|
29
23
|
with:
|
|
30
24
|
path: ws/${{ env.PACKAGE }}
|
|
25
|
+
# 2. Pin a Node that satisfies bootstrap's engines.node (>=24) so the
|
|
26
|
+
# assemble step below doesn't depend on whatever ubuntu-latest ships.
|
|
27
|
+
- uses: actions/setup-node@v5
|
|
28
|
+
with:
|
|
29
|
+
node-version: '24'
|
|
30
|
+
# 3. Assemble the workspace root: writes package.json + tinycld.packages.ts
|
|
31
|
+
# from bootstrap's embedded templates, clones app + core as siblings.
|
|
32
|
+
# The PR's PACKAGE dir is already there and is skipped.
|
|
33
|
+
- name: Assemble workspace (app + core)
|
|
34
|
+
working-directory: ws
|
|
35
|
+
run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --assemble-only
|
|
36
|
+
# 4. Now switch to the version the assembled workspace pins (ws/.node-version
|
|
37
|
+
# is written by the assemble step above).
|
|
31
38
|
- uses: actions/setup-node@v5
|
|
32
39
|
with:
|
|
33
40
|
node-version-file: ws/.node-version
|
|
34
|
-
cache: npm
|
|
35
|
-
cache-dependency-path: ws/package-lock.json
|
|
36
41
|
- uses: actions/setup-go@v6
|
|
37
42
|
with:
|
|
38
43
|
go-version-file: ws/.go-version
|
|
39
|
-
# 3. Clone app + core (the PR's PACKAGE is already checked out into its
|
|
40
|
-
# slot; bootstrap --tooling skips dirs that already exist). A sibling
|
|
41
|
-
# only needs app + core + itself to typecheck + unit-test.
|
|
42
|
-
- name: Assemble workspace (app + core)
|
|
43
|
-
working-directory: ws
|
|
44
|
-
run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --tooling
|
|
45
44
|
- name: Install (workspace root)
|
|
46
45
|
working-directory: ws
|
|
47
46
|
run: npm install
|
|
@@ -53,25 +52,24 @@ jobs:
|
|
|
53
52
|
name: E2E
|
|
54
53
|
runs-on: ubuntu-latest
|
|
55
54
|
steps:
|
|
56
|
-
- uses: actions/checkout@v5
|
|
57
|
-
with:
|
|
58
|
-
repository: tinycld/workspace
|
|
59
|
-
ref: main
|
|
60
|
-
path: ws
|
|
61
55
|
- uses: actions/checkout@v5
|
|
62
56
|
with:
|
|
63
57
|
path: ws/${{ env.PACKAGE }}
|
|
58
|
+
# Pin a Node that satisfies bootstrap's engines.node (>=24) so the
|
|
59
|
+
# assemble step below doesn't depend on whatever ubuntu-latest ships.
|
|
60
|
+
- uses: actions/setup-node@v5
|
|
61
|
+
with:
|
|
62
|
+
node-version: '24'
|
|
63
|
+
- name: Assemble workspace (app + core)
|
|
64
|
+
working-directory: ws
|
|
65
|
+
run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --assemble-only
|
|
66
|
+
# Switch to the version the assembled workspace pins.
|
|
64
67
|
- uses: actions/setup-node@v5
|
|
65
68
|
with:
|
|
66
69
|
node-version-file: ws/.node-version
|
|
67
|
-
cache: npm
|
|
68
|
-
cache-dependency-path: ws/package-lock.json
|
|
69
70
|
- uses: actions/setup-go@v6
|
|
70
71
|
with:
|
|
71
72
|
go-version-file: ws/.go-version
|
|
72
|
-
- name: Assemble workspace (app + core)
|
|
73
|
-
working-directory: ws
|
|
74
|
-
run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --tooling
|
|
75
73
|
- name: Install (workspace root)
|
|
76
74
|
working-directory: ws
|
|
77
75
|
run: npm install
|
|
@@ -17,9 +17,10 @@ linked by a single `npm install` at the root.
|
|
|
17
17
|
# In a fresh workspace directory, clone this package into a member slot…
|
|
18
18
|
git clone git@github.com:tinycld/{{PKG_SLUG}}.git
|
|
19
19
|
|
|
20
|
-
# …then
|
|
21
|
-
# package.json /
|
|
22
|
-
|
|
20
|
+
# …then assemble the rest of the workspace (app + core + the workspace
|
|
21
|
+
# package.json / tinycld.packages.ts). bootstrap --assemble-only skips
|
|
22
|
+
# dirs that already exist.
|
|
23
|
+
npx @tinycld/bootstrap@latest --assemble-only
|
|
23
24
|
|
|
24
25
|
# Link every member with one install at the WORKSPACE ROOT (never inside a
|
|
25
26
|
# member — siblings have no node_modules of their own; deps hoist to the root).
|
|
@@ -49,11 +50,11 @@ There is no `biome.json` in this repo — biome lives only in the app shell and
|
|
|
49
50
|
## CI
|
|
50
51
|
|
|
51
52
|
`.github/workflows/ci.yml` runs typecheck, unit tests, and e2e on every push to
|
|
52
|
-
`main` and every PR. It checks out
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
`tinycld-pkg check` / `tinycld-pkg test:e2e` —
|
|
56
|
-
locally.
|
|
53
|
+
`main` and every PR. It checks out this PR's code into a member slot, assembles
|
|
54
|
+
the rest of the workspace (`app` + `core` + the workspace `package.json` and
|
|
55
|
+
coordination files) via `npx @tinycld/bootstrap --assemble-only`, installs at
|
|
56
|
+
the workspace root, and runs `tinycld-pkg check` / `tinycld-pkg test:e2e` —
|
|
57
|
+
exactly what a developer runs locally.
|
|
57
58
|
|
|
58
59
|
## Package anatomy
|
|
59
60
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.25.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
25
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Test-only stub for expo-clipboard. The real package transitively
|
|
2
|
+
// imports expo-modules-core, which has too many side effects to load
|
|
3
|
+
// under a Node test environment (it touches several globals expected
|
|
4
|
+
// to be set up by Metro/RN). Tests get a minimal in-memory shim that
|
|
5
|
+
// records the last-written text and returns it on read.
|
|
6
|
+
|
|
7
|
+
let text = ''
|
|
8
|
+
|
|
9
|
+
export async function setStringAsync(s: string): Promise<void> {
|
|
10
|
+
text = s
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function getStringAsync(): Promise<string> {
|
|
14
|
+
return text
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Test-only escape hatch: reset the in-memory clipboard between tests.
|
|
18
|
+
export function __resetClipboardTextForTest(): void {
|
|
19
|
+
text = ''
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Test-only stub for expo-router. The real package's CJS entry
|
|
2
|
+
// (build/index.js) does `require("./global")` at module top, which
|
|
3
|
+
// Node's CJS resolver can't satisfy when the calling file is reached
|
|
4
|
+
// through a sibling-symlink path (Vite SSR's preserveSymlinks=false
|
|
5
|
+
// means imports under packages/@tinycld/<sibling>/... resolve via the
|
|
6
|
+
// sibling's real filesystem location, where node_modules don't reach
|
|
7
|
+
// expo-router).
|
|
8
|
+
//
|
|
9
|
+
// Tests that need to load a real screen, hook, or side-effect module
|
|
10
|
+
// from a sibling package import this stub instead. It surfaces the
|
|
11
|
+
// minimal API actually used at module-top by sibling source — the
|
|
12
|
+
// `router` object plus a few component shells. Tests that care about
|
|
13
|
+
// hook behavior should still mock those hooks individually with
|
|
14
|
+
// vi.mock; this stub only exists so the module graph can finish
|
|
15
|
+
// loading.
|
|
16
|
+
|
|
17
|
+
import { vi } from 'vitest'
|
|
18
|
+
|
|
19
|
+
const noop = () => {}
|
|
20
|
+
const ComponentStub = () => null
|
|
21
|
+
|
|
22
|
+
export const router = {
|
|
23
|
+
push: vi.fn(noop),
|
|
24
|
+
replace: vi.fn(noop),
|
|
25
|
+
back: vi.fn(noop),
|
|
26
|
+
canGoBack: vi.fn(() => true),
|
|
27
|
+
setParams: vi.fn(noop),
|
|
28
|
+
navigate: vi.fn(noop),
|
|
29
|
+
dismissAll: vi.fn(noop),
|
|
30
|
+
dismiss: vi.fn(noop),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Link = ComponentStub
|
|
34
|
+
export const Stack = Object.assign(ComponentStub, { Screen: ComponentStub })
|
|
35
|
+
export const Tabs = Object.assign(ComponentStub, { Screen: ComponentStub })
|
|
36
|
+
export const Slot = ComponentStub
|
|
37
|
+
export const Redirect = ComponentStub
|
|
38
|
+
export const Drawer = Object.assign(ComponentStub, { Screen: ComponentStub })
|
|
39
|
+
|
|
40
|
+
export const useRouter = () => router
|
|
41
|
+
export const usePathname = () => '/'
|
|
42
|
+
export const useSegments = () => []
|
|
43
|
+
export const useLocalSearchParams = () => ({})
|
|
44
|
+
export const useGlobalSearchParams = () => ({})
|
|
45
|
+
export const useFocusEffect = (_cb: () => void) => {}
|
|
46
|
+
export const useNavigation = () => ({})
|
|
47
|
+
export const useRootNavigationState = () => ({ key: 'test' })
|
|
48
|
+
export const Href = undefined
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Test-only stub for `lucide-react-native`. The real bundle pulls
|
|
2
|
+
// in `react-native-svg`, whose source uses TypeScript syntax that
|
|
3
|
+
// Vitest's transformer doesn't apply to node_modules. We don't
|
|
4
|
+
// render icons in unit tests anyway — assertions about icon
|
|
5
|
+
// rendering belong in Playwright.
|
|
6
|
+
//
|
|
7
|
+
// Implemented as CJS (.cjs) so we can use a Proxy on module.exports.
|
|
8
|
+
// Any named-icon import (ExternalLink, AlertTriangle, …) yields the
|
|
9
|
+
// same harmless component stub. Tests that genuinely need a real
|
|
10
|
+
// icon should reach for vi.mock locally; this stub only exists so
|
|
11
|
+
// the module graph can finish loading from sibling source under
|
|
12
|
+
// packages/@tinycld/<sibling>/... where the real bundle would crash.
|
|
13
|
+
//
|
|
14
|
+
// Vitest's ES↔CJS interop reads named exports from Object.keys() on
|
|
15
|
+
// the CJS module.exports, so the Proxy's `get` trap alone isn't
|
|
16
|
+
// enough — ESM `import { Heading1 } from '...'` requires Heading1 to
|
|
17
|
+
// appear as an own property. We pre-populate the dictionary with
|
|
18
|
+
// every Lucide identifier referenced from the codebase so static
|
|
19
|
+
// imports resolve to the Stub function; the Proxy handles any
|
|
20
|
+
// additional dynamic lookups (a defensive fallback).
|
|
21
|
+
|
|
22
|
+
const Stub = () => null
|
|
23
|
+
|
|
24
|
+
// Lucide icon names referenced by import statements anywhere in the
|
|
25
|
+
// codebase. Add new names here when a new icon is imported. The list
|
|
26
|
+
// is overinclusive on purpose: a missing entry would surface as an
|
|
27
|
+
// `undefined` named import in tests that try to render the icon,
|
|
28
|
+
// which is harder to diagnose than an unused name.
|
|
29
|
+
const NAMES = [
|
|
30
|
+
'AlertCircle', 'AlertTriangle', 'AlignCenter', 'AlignJustify', 'AlignLeft', 'AlignRight',
|
|
31
|
+
'Archive', 'ArrowDown', 'ArrowDownToLine', 'ArrowLeft', 'ArrowRight', 'ArrowUp',
|
|
32
|
+
'AtSign', 'Bell', 'BellOff', 'Bold', 'Book', 'BookOpen', 'Bot', 'Box', 'Calendar',
|
|
33
|
+
'Camera', 'Check', 'CheckCircle', 'CheckSquare', 'ChevronDown', 'ChevronLeft',
|
|
34
|
+
'ChevronRight', 'ChevronUp', 'Circle', 'Clipboard', 'Clock', 'Cloud', 'CloudOff',
|
|
35
|
+
'Code', 'Code2', 'Columns', 'Copy', 'CornerDownLeft', 'Cpu', 'CreditCard', 'Crop',
|
|
36
|
+
'Database', 'Download', 'Edit', 'Edit2', 'Edit3', 'ExternalLink', 'Eye', 'EyeOff',
|
|
37
|
+
'File', 'FileText', 'Files', 'Filter', 'Flag', 'Folder', 'FolderOpen', 'Forward',
|
|
38
|
+
'Globe', 'GraduationCap', 'Grid', 'HardDrive', 'Hash', 'Headphones', 'Heading1',
|
|
39
|
+
'Heading2', 'Heading3', 'Heading4', 'Heading5', 'Heading6', 'HelpCircle', 'Highlighter',
|
|
40
|
+
'Home', 'Image', 'Inbox', 'Info', 'Italic', 'Key', 'Keyboard', 'Languages', 'Layout',
|
|
41
|
+
'Link', 'Link2', 'List', 'ListChecks', 'ListOrdered', 'Loader', 'Lock', 'LogIn',
|
|
42
|
+
'LogOut', 'Mail', 'MailOpen', 'Map', 'Maximize', 'Maximize2', 'MenuIcon', 'Menu',
|
|
43
|
+
'MessageCircle', 'MessageSquare', 'Mic', 'MicOff', 'Minimize', 'Minimize2', 'Minus',
|
|
44
|
+
'MonitorSmartphone', 'MoreHorizontal', 'MoreVertical', 'Move', 'Music', 'Package',
|
|
45
|
+
'Palette', 'Paperclip', 'Pause', 'Pencil', 'Phone', 'PieChart', 'Pin', 'PinOff',
|
|
46
|
+
'Play', 'Plus', 'PlusCircle', 'Pointer', 'Power', 'Printer', 'Quote', 'RefreshCcw',
|
|
47
|
+
'RefreshCw', 'Replace', 'ReplaceAll', 'Reply', 'ReplyAll', 'RotateCcw', 'Save', 'Search', 'Send', 'Server',
|
|
48
|
+
'Settings', 'Settings2', 'Share', 'Share2', 'Shield', 'ShieldCheck', 'Shuffle',
|
|
49
|
+
'Sidebar', 'Slack', 'SlidersHorizontal', 'Smartphone', 'Smile', 'Sparkles',
|
|
50
|
+
'Speaker', 'Square', 'Star', 'Strikethrough', 'Subscript', 'Sun', 'Superscript',
|
|
51
|
+
'Table', 'Tag', 'Terminal', 'TestTube', 'Trash', 'Trash2', 'TrendingDown',
|
|
52
|
+
'TrendingUp', 'Triangle', 'Type', 'Underline', 'Undo', 'Unlock', 'Upload',
|
|
53
|
+
'User', 'UserCheck', 'UserPlus', 'Users', 'Video', 'VideoOff', 'Volume', 'Volume2',
|
|
54
|
+
'VolumeX', 'Wifi', 'WifiOff', 'WrapText', 'X', 'XCircle', 'XSquare', 'Zap',
|
|
55
|
+
'ZoomIn', 'ZoomOut',
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const exports = { default: Stub }
|
|
59
|
+
for (const name of NAMES) {
|
|
60
|
+
exports[name] = Stub
|
|
61
|
+
}
|
|
62
|
+
exports.__esModule = true
|
|
63
|
+
|
|
64
|
+
module.exports = new Proxy(exports, {
|
|
65
|
+
get(target, prop) {
|
|
66
|
+
if (typeof prop === 'symbol') return Reflect.get(target, prop)
|
|
67
|
+
if (prop in target) return target[prop]
|
|
68
|
+
// Unknown icon name: return the Stub anyway so dynamic
|
|
69
|
+
// lookups don't crash. Static `import { Foo }` will already
|
|
70
|
+
// have been resolved against `target` above.
|
|
71
|
+
return Stub
|
|
72
|
+
},
|
|
73
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { getPackages, resetPackagesCache } from '../tinycld.packages'
|
|
6
|
+
|
|
7
|
+
// Build a fake workspace tree and point getPackages at it via TINYCLD_WS_ROOT.
|
|
8
|
+
function makeWorkspace(): string {
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tcld-ws-'))
|
|
10
|
+
// core member: package.json name @tinycld/core, NO manifest.ts
|
|
11
|
+
fs.mkdirSync(path.join(root, 'core'), { recursive: true })
|
|
12
|
+
fs.writeFileSync(
|
|
13
|
+
path.join(root, 'core', 'package.json'),
|
|
14
|
+
JSON.stringify({ name: '@tinycld/core' })
|
|
15
|
+
)
|
|
16
|
+
// feature member: contacts with manifest.ts + named package.json
|
|
17
|
+
fs.mkdirSync(path.join(root, 'contacts'), { recursive: true })
|
|
18
|
+
fs.writeFileSync(path.join(root, 'contacts', 'manifest.ts'), 'export default {}')
|
|
19
|
+
fs.writeFileSync(
|
|
20
|
+
path.join(root, 'contacts', 'package.json'),
|
|
21
|
+
JSON.stringify({ name: '@tinycld/contacts' })
|
|
22
|
+
)
|
|
23
|
+
// app shell: package.json name "app", NO manifest.ts
|
|
24
|
+
fs.mkdirSync(path.join(root, 'app'), { recursive: true })
|
|
25
|
+
fs.writeFileSync(path.join(root, 'app', 'package.json'), JSON.stringify({ name: 'app' }))
|
|
26
|
+
return root
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('getPackages (new layout)', () => {
|
|
30
|
+
let root: string
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
root = makeWorkspace()
|
|
33
|
+
process.env.TINYCLD_WS_ROOT = root
|
|
34
|
+
resetPackagesCache()
|
|
35
|
+
})
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
delete process.env.TINYCLD_WS_ROOT
|
|
38
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('includes core (member named @tinycld/core, no manifest) plus feature members with a manifest.ts', () => {
|
|
42
|
+
const pkgs = getPackages()
|
|
43
|
+
expect(pkgs).toContain('@tinycld/core')
|
|
44
|
+
expect(pkgs).toContain('@tinycld/contacts')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('excludes the app shell (named "app", no manifest.ts)', () => {
|
|
48
|
+
expect(getPackages()).not.toContain('app')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('lists core first (stable order: core, then features sorted)', () => {
|
|
52
|
+
expect(getPackages()[0]).toBe('@tinycld/core')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
process.env.EXPO_PUBLIC_ENV ??= 'test'
|
|
4
|
+
|
|
5
|
+
// tinycld.config.ts is the runtime source of truth, but loading the real one
|
|
6
|
+
// pulls every linked package's collections/screens (and their un-mockable RN
|
|
7
|
+
// surface) into the test graph. Shim it to an empty config so unit tests stay
|
|
8
|
+
// light; tests that need specific packages can override per-file.
|
|
9
|
+
vi.mock('@tinycld/app-generated/tinycld-config', () => ({
|
|
10
|
+
tinycldConfig: [],
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('@sentry/react-native', () => ({
|
|
14
|
+
init: vi.fn(),
|
|
15
|
+
captureException: vi.fn(),
|
|
16
|
+
withScope: vi.fn(),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
// React Native's entry uses Flow syntax Vite/Rollup can't parse. Substitute
|
|
20
|
+
// the minimal surface our tests touch.
|
|
21
|
+
vi.mock('react-native', () => ({
|
|
22
|
+
Platform: { OS: 'web' },
|
|
23
|
+
Dimensions: {
|
|
24
|
+
get: () => ({ width: 1024, height: 768 }),
|
|
25
|
+
addEventListener: () => ({ remove: () => {} }),
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
// @react-native-async-storage/async-storage requires the RN module bridge and
|
|
30
|
+
// is pulled in transitively by `~/lib/store`. Substitute a minimal in-memory
|
|
31
|
+
// shim so tests can exercise the Zustand registry without RN.
|
|
32
|
+
vi.mock('@react-native-async-storage/async-storage', () => {
|
|
33
|
+
const store = new Map<string, string>()
|
|
34
|
+
const api = {
|
|
35
|
+
getItem: async (key: string) => store.get(key) ?? null,
|
|
36
|
+
setItem: async (key: string, value: string) => {
|
|
37
|
+
store.set(key, value)
|
|
38
|
+
},
|
|
39
|
+
removeItem: async (key: string) => {
|
|
40
|
+
store.delete(key)
|
|
41
|
+
},
|
|
42
|
+
clear: async () => {
|
|
43
|
+
store.clear()
|
|
44
|
+
},
|
|
45
|
+
getAllKeys: async () => Array.from(store.keys()),
|
|
46
|
+
multiGet: async (keys: string[]) => keys.map(k => [k, store.get(k) ?? null]),
|
|
47
|
+
multiSet: async (pairs: [string, string][]) => {
|
|
48
|
+
for (const [k, v] of pairs) store.set(k, v)
|
|
49
|
+
},
|
|
50
|
+
multiRemove: async (keys: string[]) => {
|
|
51
|
+
for (const k of keys) store.delete(k)
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
return { default: api, ...api }
|
|
55
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// New flat layout: every package (core, app, features) is a direct member dir
|
|
5
|
+
// under the workspace root. core is the member whose package.json name is
|
|
6
|
+
// "@tinycld/core" (it has no manifest.ts). Feature members are dirs with a
|
|
7
|
+
// manifest.ts. Members without a manifest.ts are excluded (this includes the app shell).
|
|
8
|
+
//
|
|
9
|
+
// TINYCLD_WS_ROOT overrides the workspace root for tests that scan a fake tree.
|
|
10
|
+
// It is resolved at call time (not module load) so that tests can set it in
|
|
11
|
+
// beforeEach after the module is already imported.
|
|
12
|
+
const CORE_NAME = '@tinycld/core'
|
|
13
|
+
|
|
14
|
+
let cached: string[] | null = null
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the package names linked into the app, core first then features
|
|
18
|
+
* sorted by name. Core is the member named "@tinycld/core" (no manifest.ts);
|
|
19
|
+
* feature members are dirs containing a manifest.ts with a named package.json.
|
|
20
|
+
* The app shell is excluded. Cached per process; reset with resetPackagesCache().
|
|
21
|
+
*/
|
|
22
|
+
export function getPackages(): string[] {
|
|
23
|
+
if (cached) return cached
|
|
24
|
+
|
|
25
|
+
const wsRoot = process.env.TINYCLD_WS_ROOT
|
|
26
|
+
? path.resolve(process.env.TINYCLD_WS_ROOT)
|
|
27
|
+
: path.resolve(import.meta.dirname)
|
|
28
|
+
|
|
29
|
+
let core: string | null = null
|
|
30
|
+
const features: string[] = []
|
|
31
|
+
|
|
32
|
+
if (fs.existsSync(wsRoot)) {
|
|
33
|
+
for (const entry of fs.readdirSync(wsRoot)) {
|
|
34
|
+
const dir = path.join(wsRoot, entry)
|
|
35
|
+
if (!isDir(dir)) continue
|
|
36
|
+
const pkgJsonPath = path.join(dir, 'package.json')
|
|
37
|
+
if (!fs.existsSync(pkgJsonPath)) continue
|
|
38
|
+
let name: unknown
|
|
39
|
+
try {
|
|
40
|
+
name = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).name
|
|
41
|
+
} catch {
|
|
42
|
+
continue // unreadable package.json
|
|
43
|
+
}
|
|
44
|
+
if (typeof name !== 'string' || name.length === 0) continue
|
|
45
|
+
if (name === CORE_NAME) {
|
|
46
|
+
core = name
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (hasManifest(dir)) features.push(name)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
features.sort()
|
|
54
|
+
cached = core ? [core, ...features] : features
|
|
55
|
+
return cached
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Test-only: clear the per-process cache so a fresh TINYCLD_WS_ROOT is read.
|
|
59
|
+
export function resetPackagesCache(): void {
|
|
60
|
+
cached = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasManifest(dir: string): boolean {
|
|
64
|
+
return (
|
|
65
|
+
fs.existsSync(path.join(dir, 'manifest.ts')) || fs.existsSync(path.join(dir, 'manifest.js'))
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isDir(p: string): boolean {
|
|
70
|
+
try {
|
|
71
|
+
return fs.statSync(p).isDirectory()
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { defineConfig } from 'vitest/config'
|
|
4
|
+
|
|
5
|
+
// Workspace-root vitest config. Supplies dedup pins, stub aliases, and the
|
|
6
|
+
// react-native/expo/sentry mocks that let the broader core suite run under
|
|
7
|
+
// Node without the Metro/RN runtime.
|
|
8
|
+
const ROOT = import.meta.dirname
|
|
9
|
+
const CORE_DIR = path.join(ROOT, 'core')
|
|
10
|
+
const APP_GENERATED = path.join(ROOT, 'app', 'lib', 'generated')
|
|
11
|
+
|
|
12
|
+
// Locate a hoisted dependency's dir. In this workspace layout deps hoist to
|
|
13
|
+
// the workspace-root node_modules (all members declare framework deps as
|
|
14
|
+
// peerDependencies only, so the app shell's direct deps live in
|
|
15
|
+
// node_modules/ here at the root). Falls back one level up (CI layouts where
|
|
16
|
+
// the workspace root is nested under another root).
|
|
17
|
+
const pkgDir = (pkg: string): string => {
|
|
18
|
+
for (const root of [path.join(ROOT, 'node_modules'), path.join(ROOT, '..', 'node_modules')]) {
|
|
19
|
+
const candidate = path.join(root, pkg)
|
|
20
|
+
if (fs.existsSync(candidate)) return candidate
|
|
21
|
+
}
|
|
22
|
+
return path.join(ROOT, 'node_modules', pkg)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
resolve: {
|
|
27
|
+
alias: [
|
|
28
|
+
// --- dedup pins (Vite SSR resolver differs from Metro; keep until
|
|
29
|
+
// a test proves the workspace dedupes these on its own) ---
|
|
30
|
+
{ find: /^react$/, replacement: path.join(pkgDir('react'), 'index.js') },
|
|
31
|
+
{
|
|
32
|
+
find: /^react\/jsx-runtime$/,
|
|
33
|
+
replacement: path.join(pkgDir('react'), 'jsx-runtime.js'),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
find: /^react\/jsx-dev-runtime$/,
|
|
37
|
+
replacement: path.join(pkgDir('react'), 'jsx-dev-runtime.js'),
|
|
38
|
+
},
|
|
39
|
+
// yjs/y-protocols use instanceof checks; a duplicate copy reached
|
|
40
|
+
// through a member symlink breaks nested Y.Map.set. Pin to the
|
|
41
|
+
// single install.
|
|
42
|
+
{ find: /^yjs$/, replacement: pkgDir('yjs') },
|
|
43
|
+
{ find: /^y-protocols\/(.+)$/, replacement: `${pkgDir('y-protocols')}/$1` },
|
|
44
|
+
// hyperformula's ESM build has broken relative imports under Vite
|
|
45
|
+
// SSR; pin to the self-consistent commonjs entry.
|
|
46
|
+
{
|
|
47
|
+
find: /^hyperformula$/,
|
|
48
|
+
replacement: path.join(pkgDir('hyperformula'), 'commonjs/index.js'),
|
|
49
|
+
},
|
|
50
|
+
// --- @tinycld/core path remaps. Unlike Metro, Vite's exports-map
|
|
51
|
+
// resolution does NOT do directory-index fallback, so
|
|
52
|
+
// `@tinycld/core/lib/notify` (a dir → lib/notify/index.ts) fails
|
|
53
|
+
// to load through the exports wildcard. Remap straight to the
|
|
54
|
+
// core source path, which Vite resolves with its own extension
|
|
55
|
+
// + index probing.
|
|
56
|
+
{
|
|
57
|
+
find: /^@tinycld\/core$/,
|
|
58
|
+
replacement: path.join(CORE_DIR, 'index.ts'),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
find: /^@tinycld\/core\/(.+)$/,
|
|
62
|
+
replacement: `${CORE_DIR}/$1`,
|
|
63
|
+
},
|
|
64
|
+
// --- @tinycld/app-generated/* build-time contract (written to
|
|
65
|
+
// app/lib/generated/ by the generator; Vitest doesn't read
|
|
66
|
+
// tsconfig paths) ---
|
|
67
|
+
{
|
|
68
|
+
find: /^@tinycld\/app-generated\/(.+)$/,
|
|
69
|
+
replacement: `${APP_GENERATED}/$1`,
|
|
70
|
+
},
|
|
71
|
+
// --- test doubles ---
|
|
72
|
+
// expo-clipboard transitively pulls in expo-modules-core, whose
|
|
73
|
+
// module-load-time side effects don't survive a bare Node test env.
|
|
74
|
+
{
|
|
75
|
+
find: /^expo-clipboard$/,
|
|
76
|
+
replacement: path.join(ROOT, 'tests/expo-clipboard-stub.ts'),
|
|
77
|
+
},
|
|
78
|
+
// expo-router's CJS entry does `require("./global")` at module top,
|
|
79
|
+
// which Node can't resolve when reached through a member symlink.
|
|
80
|
+
{
|
|
81
|
+
find: /^expo-router$/,
|
|
82
|
+
replacement: path.join(ROOT, 'tests/expo-router-stub.ts'),
|
|
83
|
+
},
|
|
84
|
+
// lucide-react-native pulls in react-native-svg (TS source, not
|
|
85
|
+
// transformed in node_modules under Vitest); a CJS Proxy stub
|
|
86
|
+
// yields a harmless component for any named icon import.
|
|
87
|
+
{
|
|
88
|
+
find: /^lucide-react-native$/,
|
|
89
|
+
replacement: path.join(ROOT, 'tests/lucide-react-native-stub.cjs'),
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
test: {
|
|
94
|
+
environment: 'node',
|
|
95
|
+
include: [
|
|
96
|
+
'tests/**/*.test.{ts,tsx}',
|
|
97
|
+
'core/**/__tests__/**/*.test.{ts,tsx}',
|
|
98
|
+
'core/**/*.test.{ts,tsx}',
|
|
99
|
+
'app/scripts/**/__tests__/**/*.test.{ts,tsx}',
|
|
100
|
+
'package-scripts/tests/**/*.test.{ts,tsx}',
|
|
101
|
+
],
|
|
102
|
+
setupFiles: ['tests/unit-setup.ts'],
|
|
103
|
+
},
|
|
104
|
+
})
|