@_linked/cli 1.13.0 → 1.15.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/CHANGELOG.md +71 -0
- package/README.md +78 -38
- package/defaults/app-react-native/README.md +62 -22
- package/defaults/app-react-native/apps/mobile/App.tsx +6 -3
- package/defaults/app-react-native/apps/mobile/__tests__/composePort.test.ts +33 -0
- package/defaults/app-react-native/apps/mobile/__tests__/env.test.ts +39 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/apiEnv.ts +75 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/globalSetup.ts +160 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/globalTeardown.ts +20 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/nodeFetchEnvironment.js +13 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/personOverview.test.tsx +51 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/restoreNodeFetch.ts +8 -0
- package/defaults/app-react-native/apps/mobile/__tests__/{linkedDefaults.test.tsx → nativeDefaults.test.tsx} +5 -7
- package/defaults/app-react-native/apps/mobile/__tests__/storage.test.ts +24 -0
- package/defaults/app-react-native/apps/mobile/__tests__/stripJsonImportAttributes.test.ts +45 -0
- package/defaults/app-react-native/apps/mobile/app.config.ts +21 -0
- package/defaults/app-react-native/apps/mobile/babel/stripJsonImportAttributes.js +41 -0
- package/defaults/app-react-native/apps/mobile/babel.config.js +13 -0
- package/defaults/app-react-native/apps/mobile/jest.integration.config.js +16 -0
- package/defaults/app-react-native/apps/mobile/package.json +11 -3
- package/defaults/app-react-native/apps/mobile/src/components/PersonOverview.tsx +123 -0
- package/defaults/app-react-native/apps/mobile/src/components/PersonOverviewContext.tsx +12 -0
- package/defaults/app-react-native/apps/mobile/src/components/PersonPreview.tsx +135 -0
- package/defaults/app-react-native/apps/mobile/src/shell/apiPort.json +1 -0
- package/defaults/app-react-native/apps/mobile/src/shell/env.ts +30 -0
- package/defaults/app-react-native/apps/mobile/src/shell/storage.ts +9 -0
- package/defaults/app-react-native/docker-compose.yml +22 -0
- package/defaults/app-react-native/eslint.config.js +47 -0
- package/defaults/app-react-native/gitignore.template +5 -0
- package/defaults/app-react-native/package.json +14 -4
- package/defaults/app-react-native/packages/app-shapes/package.json +17 -3
- package/defaults/app-react-native/packages/app-shapes/tsconfig-esm.json +12 -0
- package/defaults/app-react-native/scripts/check-react.mjs +55 -0
- package/defaults/app-react-native/services/api/README.md +50 -2
- package/defaults/app-react-native/services/api/env.example.template +11 -0
- package/defaults/app-react-native/services/api/linked.backend.datasets.json +15 -0
- package/defaults/app-react-native/services/api/linked.backend.storage.ts +35 -0
- package/defaults/app-react-native/services/api/package.json +27 -1
- package/defaults/app-react-native/services/api/scripts/wait-for-fuseki.mjs +26 -0
- package/defaults/app-react-native/services/api/src/backend.ts +9 -0
- package/defaults/app-react-native/services/api/src/fileStoreEnv.ts +14 -0
- package/defaults/app-react-native/services/api/test/hasS3Env.test.mjs +28 -0
- package/defaults/app-react-native/services/api/test/waitForFuseki.test.mjs +27 -0
- package/defaults/app-react-native/services/api/tsconfig.json +15 -0
- package/lib/esm/cli-methods.d.ts +7 -2
- package/lib/esm/cli-methods.d.ts.map +1 -1
- package/lib/esm/cli-methods.js +147 -104
- package/lib/esm/cli-methods.js.map +1 -1
- package/lib/esm/cli.js +24 -11
- package/lib/esm/cli.js.map +1 -1
- package/lib/esm/commands/start.d.ts.map +1 -1
- package/lib/esm/commands/start.js +12 -8
- package/lib/esm/commands/start.js.map +1 -1
- package/lib/esm/utils/esmSpecifiers.d.ts +12 -2
- package/lib/esm/utils/esmSpecifiers.d.ts.map +1 -1
- package/lib/esm/utils/esmSpecifiers.js +57 -14
- package/lib/esm/utils/esmSpecifiers.js.map +1 -1
- package/lib/esm/utils/shippedDotfiles.d.ts.map +1 -1
- package/lib/esm/utils/shippedDotfiles.js +3 -1
- package/lib/esm/utils/shippedDotfiles.js.map +1 -1
- package/lib/esm/utils.d.ts +16 -7
- package/lib/esm/utils.d.ts.map +1 -1
- package/lib/esm/utils.js +92 -40
- package/lib/esm/utils.js.map +1 -1
- package/lib/esm/vite-config.js +6 -6
- package/lib/esm/vite-config.js.map +1 -1
- package/package.json +1 -1
- package/defaults/app-react-native/apps/mobile/src/shell/linkedDefaults.tsx +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#86](https://github.com/linked-cm/cli/pull/86) [`1f8908f`](https://github.com/linked-cm/cli/commit/1f8908fa276a5b65ed3ffe1c1e3bc23417ef9043) Thanks [@flyon](https://github.com/flyon)! - Always rewrite emitted ESM import specifiers
|
|
8
|
+
|
|
9
|
+
`linked build` appends `.js` (or `/index.js`) to relative specifiers in `lib/esm` for every package, with no
|
|
10
|
+
opt-in. The rewrite covers the emitted `.d.ts` declarations as well as the `.js`, so consumers on TypeScript
|
|
11
|
+
`node16`/`nodenext` resolution get valid declarations. For a package that already writes `.js` specifiers the
|
|
12
|
+
rewrite is a no-op.
|
|
13
|
+
|
|
14
|
+
It runs at the end of the build, after the assets and hand-written declarations have been copied into `lib` and
|
|
15
|
+
after stale output has been removed, so every specifier is resolved against the files the package actually ships.
|
|
16
|
+
Asset imports such as `./styles.scss` resolve and are left as written, copied `.d.ts` files are rewritten too, and
|
|
17
|
+
a leftover `x.js` can no longer make `./x` point at a file the cleanup then deletes.
|
|
18
|
+
|
|
19
|
+
Also in this release:
|
|
20
|
+
|
|
21
|
+
- The `missing_extension` rule is gone from the import check, in the build gate and in `linked check-imports`.
|
|
22
|
+
Extensionless relative imports in source no longer fail the build; the rewrite warns about any relative
|
|
23
|
+
specifier it cannot resolve, and Vite, Metro and `tsc` catch the rest. `outside_package` and the
|
|
24
|
+
internal-import rule remain hard errors for every package.
|
|
25
|
+
- The rule that stops a package reaching into another Linked package's internals now actually fires. It matched
|
|
26
|
+
only import paths containing the literal string `lincd`, so after the rename to the `@_linked/*` scope it was a
|
|
27
|
+
no-op for every current package: `@_linked/core/lib/esm/utils/Shape` passed. It now matches `@_linked/<pkg>/...`
|
|
28
|
+
as well as `lincd`-prefixed package names, on a `/src/` or `/lib/` path segment, and is called
|
|
29
|
+
`isInternalLinkedImport`. This can fail builds that previously passed — which is the point: such an import
|
|
30
|
+
breaks when the other package changes its build layout and bypasses its exports map. Import the public subpath
|
|
31
|
+
instead. Relative specifiers are exempt, so a local `./lib/helpers` is still fine.
|
|
32
|
+
- Both import rules now see every form that names a module — `export ... from`, `import()` in value and type
|
|
33
|
+
position, and `import x = require()` — not just `import ... from`.
|
|
34
|
+
- `linked check-imports` prints the report and exits 1 when an import is invalid, instead of exiting 0.
|
|
35
|
+
- `linked build` reports failures accurately: a step that fails names itself instead of a bare "Build failed",
|
|
36
|
+
and a failed build always exits non-zero, including under `--silent` and when building updated packages.
|
|
37
|
+
- `isImportOutsideOfPackage` normalises the path before judging it, so `./a..b` is no longer flagged and
|
|
38
|
+
`../a/../b` is counted as the one level it actually climbs.
|
|
39
|
+
- A relative specifier with a trailing slash (`./file/`) names a directory, so it resolves to `./file/index.js`
|
|
40
|
+
and never to a sibling `./file.js`.
|
|
41
|
+
|
|
42
|
+
A package without a `tsconfig-esm.json` has no ESM build, so the rewrite step is skipped rather than failing. When
|
|
43
|
+
`tsconfig-esm.json` is present but `lib/esm` was not emitted, the build fails.
|
|
44
|
+
|
|
45
|
+
## 1.14.1
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- [#83](https://github.com/linked-cm/cli/pull/83) [`566cb44`](https://github.com/linked-cm/cli/commit/566cb44f8728f25e852987ed4426063df9575492) Thanks [@flyon](https://github.com/flyon)! - React Native template: the integration test setup now tells apart "Docker is not installed", "Docker Compose is unavailable" and "Compose Fuseki is not running" (unit-tested in the scaffold); the integration Jest config comment says "Integration tests". README: document that `linked build` exits 1 whenever the build does not succeed (including in a `linkedApp` or a package without `linkedPackage: true`), and 0 with warnings. Reworded a stale migration comment in `linked start`.
|
|
50
|
+
|
|
51
|
+
## 1.14.0
|
|
52
|
+
|
|
53
|
+
### Minor Changes
|
|
54
|
+
|
|
55
|
+
- [#81](https://github.com/linked-cm/cli/pull/81) [`d1c0729`](https://github.com/linked-cm/cli/commit/d1c07296a9470ed3c995ce83a9ade22c0632395c) Thanks [@flyon](https://github.com/flyon)! - `create-app --template react-native` now scaffolds the Linked backend next to the app.
|
|
56
|
+
|
|
57
|
+
- `services/api`: an API-only Linked backend (`linked start --api-only`) on `@_linked/server`, with Fuseki through `linked.backend.datasets.json`, a local file store (or `S3FileStore` when the S3 variables are set), a Fuseki reachability check and `node --test` unit tests.
|
|
58
|
+
- A root `docker-compose.yml` for Fuseki (`secoresearch/fuseki:5.5.0`, host port `FUSEKI_PORT`), root scripts `fuseki:up`, `fuseki:down`, `api`, `check:react`, `lint`, `typecheck`, `test` and `test:integration`, a root ESLint flat config (with a rule that keeps `apps/mobile` on `@_linked/server` subpath imports) and a "Before every PR" checklist in the README (the template ships no CI workflow).
|
|
59
|
+
- Pins `@_linked/server` 2.3.0, `@_linked/server-utils` 1.2.0 and `@_linked/cli` 1.13.0.
|
|
60
|
+
- `apps/mobile/babel.config.js` registers a Babel plugin that strips JSON import attributes (`import('x.json', { with: { type: 'json' } })`), so Metro bundles Linked ontology packages.
|
|
61
|
+
- `resolveApiUrl` ignores a non-string `extra.apiUrl`: the dev-client manifest delivers `null` as `{}`, which crashed the app with "LincdServerProxy requires a root URL".
|
|
62
|
+
- `apps/mobile` imports `@_linked/react/native` (1.5.0) instead of its own render defaults, resolves the API URL in `app.config.ts` and `src/shell/env.ts`, and sends queries to the API through `BackendAPIStore`. The root `overrides` entry is gone.
|
|
63
|
+
- An example add/edit/delete screen (`PersonOverview`, `PersonPreview`), ported from the web app-template to React Native with `@_linked/schema`'s `Person`, with a Jest integration test against the running API and Fuseki.
|
|
64
|
+
- The shapes package sets `"linked": {"extensionlessImports": true}`, so `linked build` emits Node-loadable `lib/esm`.
|
|
65
|
+
- With `linked.extensionlessImports`, `linked build` fails when `lib/esm` was not emitted (no `tsconfig-esm.json`), finishes "with warnings" listing every relative specifier it could not resolve, and handles `'.'`, `'..'` and `'./dir/'`. `linked build` now exits non-zero when the build fails.
|
|
66
|
+
- README: documents `linked start --api-only` / `server.apiOnly`, the `extensionlessImports` flag, and the react-native template as it is now.
|
|
67
|
+
- Integration tests only reset a Fuseki that is this repo's Compose `fuseki` on localhost (`docker compose port fuseki 3030`) with a `-test` dataset. They read `services/api/.env` (the shell wins), pass every `FUSEKI_*` variable to the API explicitly, blank the S3 variables (removing `AWS_REGION`, which `@_linked/s3` rejects when empty), and kill the API's process group on timeout, early exit, SIGINT, SIGTERM and exit. `INTEGRATION_API_BIN` and `INTEGRATION_API_TIMEOUT_MS` exist for testing that cleanup.
|
|
68
|
+
- Load order is enforced by imports: `storage.ts` imports `env.ts`, and the example linked components import `storage.ts`. The default API port is shared from `apps/mobile/src/shell/apiPort.json`.
|
|
69
|
+
- `wait-for-fuseki.mjs` honours `WAIT_FOR_FUSEKI_TIMEOUT_MS`. New tests: `storage.test.ts` (load order) and `waitForFuseki.test.mjs`. Local uploads go to `services/api/data/uploads/`, which is gitignored.
|
|
70
|
+
- `linked call` (a direct `callBackendMethod`) now prints the error and exits 1 when the method is unmatched or fails, instead of an unhandled rejection.
|
|
71
|
+
- The Fuseki dataset names (`<prefix>-dev`, `<prefix>-test`) are stamped from the app prefix.
|
|
72
|
+
- Shipped dotfiles are restored at any depth, and `env.example.template` becomes `.env.example`.
|
|
73
|
+
|
|
3
74
|
## 1.13.0
|
|
4
75
|
|
|
5
76
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -41,6 +41,10 @@ linked build-updated # incremental: only packages that changed sinc
|
|
|
41
41
|
linked build-package <file> # walk up from a file path to find its package and rebuild
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
`linked build` exits with code 1 whenever the build does not succeed, including when it is run in a `linkedApp` or
|
|
45
|
+
in a package without `"linkedPackage": true` (it used to exit 0 there). A build that finishes with warnings still
|
|
46
|
+
exits 0.
|
|
47
|
+
|
|
44
48
|
### Publishing / release
|
|
45
49
|
|
|
46
50
|
```bash
|
|
@@ -62,6 +66,7 @@ linked setup-publish --scope community # use NPM_AUTH_TOKEN_CM instead of N
|
|
|
62
66
|
|
|
63
67
|
```bash
|
|
64
68
|
linked start # run the dev server (app)
|
|
69
|
+
linked start --api-only # run only the backend API (no page rendering, no vite.config needed)
|
|
65
70
|
linked dev # file-watch rebuild (package)
|
|
66
71
|
linked yarn <args> # safe-yarn: run yarn at root while preserving nested yarn.lock files
|
|
67
72
|
```
|
|
@@ -88,6 +93,38 @@ The CLI recognizes two flags in `package.json`:
|
|
|
88
93
|
|
|
89
94
|
The legacy `lincd: true` / `lincdApp: true` flags are no longer read. Migrate to `linkedPackage` / `linkedApp`.
|
|
90
95
|
|
|
96
|
+
### Extensionless relative imports
|
|
97
|
+
|
|
98
|
+
Package source may use extensionless relative imports (`./shapes/Example` rather than `./shapes/Example.js`) —
|
|
99
|
+
useful for a shapes package consumed as TypeScript source by Metro, which does not map `.js` specifiers to `.ts`
|
|
100
|
+
files. No configuration is needed. After compiling ESM, `linked build` rewrites every relative specifier in
|
|
101
|
+
`lib/esm` — in the emitted `.js` and in the `.d.ts` declarations beside them, so consumers on TypeScript
|
|
102
|
+
`node16`/`nodenext` resolution get valid declarations — to `./x.js` or `./dir/index.js`, so Node can load the
|
|
103
|
+
output. For a package that already writes `.js` specifiers the rewrite is a no-op.
|
|
104
|
+
|
|
105
|
+
The build finishes "with warnings" and lists each relative specifier it could not resolve (left unchanged), and
|
|
106
|
+
fails when `tsconfig-esm.json` is present but `lib/esm` was not emitted. A package without a `tsconfig-esm.json`
|
|
107
|
+
has no ESM build, so the step is skipped.
|
|
108
|
+
|
|
109
|
+
The import check (`linked check-imports`, also run as the first build step) still fails the build for imports
|
|
110
|
+
that reach outside the package source root, and for imports into another linked package's `/src/` or `/lib/`.
|
|
111
|
+
|
|
112
|
+
## API-only backend
|
|
113
|
+
|
|
114
|
+
`linked start --api-only`, or `server.apiOnly: true` in `linked.config.js`, serves only the backend API
|
|
115
|
+
(`/call/...`, `/api/...` and provider routes). There is no server-side page rendering, page requests get a 404,
|
|
116
|
+
and no `vite.config.*` is required. Use it for a backend whose frontend lives elsewhere, such as a React Native
|
|
117
|
+
app:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
// linked.config.js
|
|
121
|
+
export default {
|
|
122
|
+
server: {apiOnly: true},
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
API-only mode is never inferred: a web app without a `vite.config.*` and without the flag still fails at startup.
|
|
127
|
+
|
|
91
128
|
## Development
|
|
92
129
|
|
|
93
130
|
```bash
|
|
@@ -110,68 +147,71 @@ Templates live in `defaults/`:
|
|
|
110
147
|
|
|
111
148
|
`linked create-app <name>` copies `defaults/app-with-backend/` to the new app's folder, substitutes `${name}` / `${hyphen_name}` / `${app_prefix}` / `${app_domain}` placeholders in selected files, and copies `linked.backend.datasets.example.json` → `linked.backend.datasets.json` so first boot works zero-config.
|
|
112
149
|
|
|
113
|
-
Storage configuration follows the two-layer pattern from [backlog 016](https://github.com/create-
|
|
114
|
-
|
|
115
|
-
| File | Side | Role | Git |
|
|
116
|
-
|---|---|---|---|
|
|
117
|
-
| `linked.backend.storage.ts` | backend | Shape→alias routing. Uses `parseDatasetsConfig` + `loadStores` from `@_linked/core`; calls `LinkedStorage.setDefaultDataset(...)` / `setDatasetForShapes(...)`. | committed |
|
|
118
|
-
| `linked.backend.datasets.json` | backend | Alias → `{ store, config }`. `store` is an npm import path; `config` is the store class's constructor arg. `${VAR:-default}` placeholders resolved at boot. | **gitignored** |
|
|
119
|
-
| `linked.backend.datasets.example.json` | backend | Template / seed for the gitignored file. Has placeholders matching env vars. | committed |
|
|
120
|
-
| `src/linked.frontend.storage.ts` | frontend | Same shape→alias model; imports store classes explicitly (webpack-bundle-safe), constructs per alias. | committed |
|
|
121
|
-
| `src/linked.frontend.datasets.json` | frontend | Frontend alias → `{ store, config }`. Public values only — never put secrets here, it ships in the browser bundle. | committed |
|
|
122
|
-
|
|
123
|
-
Conventions:
|
|
124
|
-
- Aliases on the two sides are **independent**. The framework re-routes by shape on each side; matching alias names between FE and BE is convention, not a framework requirement.
|
|
125
|
-
- The backend dispatcher is registry-free (dynamic `await import(entry.store)`). The frontend hardcodes each `new StoreClass(config)` because webpack can't bundle dynamic imports of arbitrary npm specifiers.
|
|
126
|
-
- Each store class accepts a single config-object constructor argument — `new FusekiStore({ endpoint, credentials? })`, `new BackendAPIStore({ name?, id? })`.
|
|
127
|
-
|
|
128
|
-
### `linked create-app --template react-native`
|
|
150
|
+
Storage configuration follows the two-layer pattern from [backlog 016](https://github.com/c### `linked create-app --template react-native`
|
|
129
151
|
|
|
130
152
|
`linked create-app <name> --template react-native` (with the usual `--app-name`, `--app-prefix`, `--app-domain`,
|
|
131
153
|
`--skip-install`) copies `defaults/app-react-native/` instead of cloning the web template. The result is an npm
|
|
132
|
-
workspaces monorepo for Expo SDK 57 / React Native 0.86 / React 19.2:
|
|
154
|
+
workspaces monorepo for Expo SDK 57 / React Native 0.86 / React 19.2 with an API-only Linked backend on Fuseki:
|
|
133
155
|
|
|
134
156
|
```
|
|
135
157
|
<name>/
|
|
136
|
-
package.json
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
158
|
+
package.json enumerated workspaces; scripts fuseki:up/down, api, check:react, lint, typecheck,
|
|
159
|
+
test, test:integration
|
|
160
|
+
docker-compose.yml local Fuseki (secoresearch/fuseki, host port FUSEKI_PORT, default 3030)
|
|
161
|
+
eslint.config.js flat config; keeps apps/mobile on @_linked/server subpath imports
|
|
162
|
+
apps/mobile/ Expo app (ios/ and android/ are generated, not committed)
|
|
163
|
+
App.tsx imports ./src/shell/env, @_linked/react/native, ./src/shell/storage
|
|
164
|
+
babel.config.js registers babel/stripJsonImportAttributes.js
|
|
165
|
+
src/shell/ env.ts (API URL), storage.ts (BackendAPIStore)
|
|
166
|
+
src/components/ PersonOverview / PersonPreview: example add/edit/delete screen
|
|
167
|
+
__tests__/ unit tests; __tests__/integration/ runs against the API and Fuseki
|
|
168
|
+
packages/<prefix>-shapes/ Linked shapes package: TypeScript source with extensionless imports; `linked build`
|
|
169
|
+
rewrites the emitted specifiers so lib/esm is Node-loadable
|
|
170
|
+
services/api/ API-only backend (`linked start --api-only`) on @_linked/server, Fuseki through
|
|
171
|
+
linked.backend.datasets.json, node --test unit tests
|
|
144
172
|
```
|
|
145
173
|
|
|
146
174
|
What the scaffold does:
|
|
147
175
|
|
|
148
176
|
- Refuses, before writing anything, a prefix that does not match `^[a-z][a-z0-9-]*$`, and a target folder that
|
|
149
177
|
exists and is not empty.
|
|
150
|
-
-
|
|
178
|
+
- Restores shipped dotfiles at any depth (`gitignore.template` becomes `.gitignore`, `env.example.template`
|
|
179
|
+
becomes `.env.example`). npm strips real dotfiles like `.gitignore` from the CLI tarball.
|
|
151
180
|
- Renames `packages/app-shapes` to `packages/<prefix>-shapes`, and replaces the literal `app-shapes` token in
|
|
152
181
|
every text file.
|
|
153
182
|
- Sets the root `name` to `<hyphen-name>-monorepo`, and `app.json` `expo.name`, `expo.slug` and
|
|
154
|
-
`expo.ios.bundleIdentifier`. The bundle ID is the reversed domain plus the prefix, e.g.
|
|
155
|
-
|
|
183
|
+
`expo.ios.bundleIdentifier`. The bundle ID is the reversed domain plus the prefix, e.g. `com.example.demo`.
|
|
184
|
+
- Stamps the Fuseki dataset names `<prefix>-dev` and `<prefix>-test` from the prefix.
|
|
156
185
|
- Never runs the `${…}` placeholder substitution, so template literals in the sources are left alone.
|
|
157
186
|
- Installs with `npm install` (never Yarn) unless `--skip-install` is given. If the install fails, the files
|
|
158
187
|
stay and the command exits non-zero; run `npm install` in the folder to retry.
|
|
159
188
|
|
|
160
|
-
|
|
161
|
-
them from `app.json`, and `apps/mobile/.gitignore` ignores them.
|
|
189
|
+
The generated app:
|
|
162
190
|
|
|
163
|
-
|
|
191
|
+
- **Render defaults** come from `@_linked/react/native`, which replaces `@_linked/react`'s `<svg>` loader and
|
|
192
|
+
error elements (they crash on React Native).
|
|
193
|
+
- **Person example.** `PersonOverview` and `PersonPreview` list, add, edit and delete `@_linked/schema`'s
|
|
194
|
+
`Person` through `BackendAPIStore` → API → Fuseki. A Jest integration test covers it.
|
|
195
|
+
- **Babel plugin.** Metro rejects `import('x.json', { with: { type: 'json' } })`, which Linked ontology packages
|
|
196
|
+
use; `apps/mobile/babel/stripJsonImportAttributes.js` drops that options argument.
|
|
197
|
+
- **Before every PR.** The template ships no CI workflow. Its README has a checklist to run locally and paste
|
|
198
|
+
into the PR:
|
|
164
199
|
|
|
165
200
|
```bash
|
|
166
|
-
npm
|
|
167
|
-
npm
|
|
168
|
-
|
|
169
|
-
|
|
201
|
+
npm run check:react
|
|
202
|
+
npm run lint
|
|
203
|
+
npm run typecheck
|
|
204
|
+
npm test
|
|
205
|
+
npm run fuseki:up && npm run test:integration
|
|
206
|
+
cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export
|
|
170
207
|
```
|
|
171
208
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
209
|
+
To run it: `npm run fuseki:up`, `npm run api`, then `cd apps/mobile && npx expo run:ios`.
|
|
210
|
+
|
|
211
|
+
Tests for this template in this repo: `yarn test:unit` covers the scaffold offline, using the fixture in
|
|
212
|
+
`tests/fixtures/app-react-native-min` and the real template. `npm run test:template` runs the built CLI with
|
|
213
|
+
install, then checks the generated files, `check:react`, lint, typecheck, `npm test`, the shapes build and a
|
|
214
|
+
Metro iOS bundle. It builds nothing itself, needs network and takes several minutes.
|
|
175
215
|
|
|
176
216
|
## Repository
|
|
177
217
|
|
|
@@ -1,42 +1,67 @@
|
|
|
1
1
|
# Linked React Native monorepo
|
|
2
2
|
|
|
3
3
|
An npm-workspaces monorepo for a [Linked](https://github.com/linked-cm) app on Expo SDK 57 / React Native 0.86 /
|
|
4
|
-
React 19.2.
|
|
4
|
+
React 19.2, with an API-only Linked backend on Fuseki.
|
|
5
5
|
|
|
6
6
|
```
|
|
7
7
|
apps/mobile/ Expo app (continuous native generation: ios/ is generated, not committed)
|
|
8
|
-
src/shell/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
src/shell/ env (API URL) and storage (BackendAPIStore) wiring
|
|
9
|
+
src/components/ PersonOverview / PersonPreview: example add/edit/delete screen, safe to replace
|
|
10
|
+
__tests__/ unit tests; __tests__/integration/ runs against a real API and Fuseki
|
|
11
|
+
packages/app-shapes/ Linked shapes package, consumed as TypeScript source; `linked build` for Node
|
|
12
|
+
services/api/ Linked backend (`linked start --api-only`), see services/api/README.md
|
|
13
|
+
docker-compose.yml local Fuseki
|
|
12
14
|
```
|
|
13
15
|
|
|
14
16
|
## Run
|
|
15
17
|
|
|
18
|
+
Requires Node `^22.13 || >=24` and Docker. iOS builds need macOS 26.2+ and Xcode 26.4+ with the iOS platform
|
|
19
|
+
component installed.
|
|
20
|
+
|
|
16
21
|
```bash
|
|
17
22
|
npm install
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
cp services/api/.env.example services/api/.env # once
|
|
24
|
+
npm run fuseki:up # Fuseki on :3030 (Docker Compose)
|
|
25
|
+
npm run api # API-only backend on :4000
|
|
26
|
+
cd apps/mobile && npx expo run:ios # generates ios/, builds a dev client, starts Metro
|
|
20
27
|
```
|
|
21
28
|
|
|
22
29
|
`expo-dev-client` is a dependency of `apps/mobile`, so `expo run:ios` produces a custom dev client (not Expo Go),
|
|
23
|
-
which is what native modules need.
|
|
30
|
+
which is what native modules need. The app finds the API on the dev machine's host (see "API URL" below).
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
If port 3030 is taken, see "Port 3030 taken" in `services/api/README.md`: set `FUSEKI_PORT` in a root `.env`
|
|
33
|
+
and `FUSEKI_BASE_URL` in `services/api/.env`.
|
|
27
34
|
|
|
28
|
-
##
|
|
35
|
+
## Checks
|
|
29
36
|
|
|
30
37
|
| Command | What it proves |
|
|
31
38
|
|---|---|
|
|
32
|
-
| `npm
|
|
33
|
-
| `npm
|
|
39
|
+
| `npm run check:react` | `apps/mobile`, `@_linked/react` and `react-native` resolve one React 19.2.3 |
|
|
40
|
+
| `npm run typecheck` | `tsc --noEmit` in every workspace |
|
|
41
|
+
| `npm run lint` | ESLint over every workspace, including the `@_linked/server` subpath rule for `apps/mobile` |
|
|
42
|
+
| `npm test` | app Jest (shapes, render defaults, API URL) and the API's `node --test` unit tests |
|
|
43
|
+
| `npm run fuseki:up && npm run test:integration` | the example screen lists, adds and deletes through `BackendAPIStore` → API → Fuseki (starts its own API on :4100 with the in-memory `app-test` dataset) |
|
|
44
|
+
| `npm run build -w packages/app-shapes` | `linked build` emits `lib/esm` with `.js` specifiers, loadable by Node |
|
|
34
45
|
| `cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export` | Metro resolves `@_linked/*` (including subpaths) and the workspace packages |
|
|
35
46
|
|
|
47
|
+
## Before every PR
|
|
48
|
+
|
|
49
|
+
There is no CI workflow. Run every check locally before opening or updating a PR, and paste the results into the
|
|
50
|
+
PR description:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm run check:react
|
|
54
|
+
npm run lint
|
|
55
|
+
npm run typecheck
|
|
56
|
+
npm test
|
|
57
|
+
npm run fuseki:up && npm run test:integration
|
|
58
|
+
cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export
|
|
59
|
+
```
|
|
60
|
+
|
|
36
61
|
## React Native specifics
|
|
37
62
|
|
|
38
|
-
- **One React.**
|
|
39
|
-
|
|
63
|
+
- **One React.** `npm ls react` lists extra React copies nested under `@_linked/server` and
|
|
64
|
+
`@_linked/server-utils`; the app never loads them, which `npm run check:react` verifies. Do not use
|
|
40
65
|
`--legacy-peer-deps`: it would hide a duplicate React.
|
|
41
66
|
- **Workspaces are enumerated**, never globbed, so foreign repositories checked out under `packages/` are not
|
|
42
67
|
adopted. `.gitignore` uses `packages/*` plus a negation per workspace package (`packages/` cannot be negated).
|
|
@@ -46,11 +71,26 @@ installed.
|
|
|
46
71
|
subpaths.
|
|
47
72
|
- **Jest** maps `@_linked/*` to its ESM build with a scoped `moduleNameMapper`. A global
|
|
48
73
|
`customExportConditions` would break `@babel/runtime`. Add every workspace package to
|
|
49
|
-
`transformIgnorePatterns`.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
`transformIgnorePatterns`. The integration config restores Node's `fetch`, which jest-expo replaces with a
|
|
75
|
+
stub that never sends a request.
|
|
76
|
+
- **Render defaults.** `@_linked/react`'s root loader and error elements are `<svg>`, which crash on React
|
|
77
|
+
Native. Importing `@_linked/react/native` installs React Native defaults; `__tests__/nativeDefaults.test.tsx`
|
|
78
|
+
guards it.
|
|
79
|
+
- **API URL.** `app.config.ts` passes `EXPO_PUBLIC_API_URL` (if set) and the API port (`defaultApiPort` in
|
|
80
|
+
`src/shell/apiPort.json`, shared with `env.ts`) to `src/shell/env.ts`,
|
|
81
|
+
which falls back to the dev machine's host from Expo's `hostUri`, then `localhost`, and sets
|
|
82
|
+
`process.env.SITE_ROOT` and `DATA_ROOT` for `BackendAPIStore`. The app identity (name, slug, bundle id) stays in
|
|
83
|
+
`app.json`.
|
|
84
|
+
- **Load order is enforced by imports:** `App.tsx` imports `@_linked/react/native` first, and every module that
|
|
85
|
+
needs the API store imports `src/shell/storage`, which imports `src/shell/env`.
|
|
86
|
+
- **`@_linked/server` by subpath only** in `apps/mobile` (for example
|
|
87
|
+
`@_linked/server/shapes/quadstores/BackendAPIStore`): the root barrel pulls express, webpack and react-dom.
|
|
88
|
+
ESLint `no-restricted-imports` enforces it.
|
|
53
89
|
- **Shapes package imports are extensionless** (`./shapes/Example`, not `./shapes/Example.js`): Metro does not
|
|
54
|
-
map `.js` specifiers to `.ts` source.
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
map `.js` specifiers to `.ts` source. No configuration is needed — `linked build` always adds `.js` to the
|
|
91
|
+
relative specifiers in the emitted `lib/esm` JS and declarations. The app, Jest and the API in development
|
|
92
|
+
consume `src/` through the `react-native` and `development` export conditions.
|
|
93
|
+
- **Decorators** work with the stock `babel-preset-expo`. The one custom Babel plugin,
|
|
94
|
+
`apps/mobile/babel/stripJsonImportAttributes.js`, strips `with { type: 'json' }` from dynamic `import()`, which
|
|
95
|
+
Metro rejects and Linked ontology packages use; `__tests__/stripJsonImportAttributes.test.ts` covers it. After
|
|
96
|
+
editing `babel.config.js` or the plugin, restart with `npx expo start --clear`.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
import
|
|
1
|
+
// @_linked/react/native first: it installs the React Native render defaults before any component renders. Modules
|
|
2
|
+
// that need the API store import ./src/shell/storage themselves, which imports ./src/shell/env.
|
|
3
|
+
import '@_linked/react/native';
|
|
4
|
+
import { Example } from 'app-shapes';
|
|
3
5
|
|
|
4
6
|
import { StyleSheet, Text, View } from 'react-native';
|
|
5
7
|
import { getShapeClass } from '@_linked/core/utils/ShapeClass';
|
|
6
|
-
import {
|
|
8
|
+
import { PersonOverview } from './src/components/PersonOverview';
|
|
7
9
|
|
|
8
10
|
export default function App() {
|
|
9
11
|
const registered = getShapeClass(Example.shape.id) === Example;
|
|
@@ -12,6 +14,7 @@ export default function App() {
|
|
|
12
14
|
{/* @linkedShape adds static packageName at runtime; it is not on the class type. */}
|
|
13
15
|
<Text>{(Example as any).packageName}</Text>
|
|
14
16
|
<Text>{registered ? 'shapes registered' : 'shapes not registered'}</Text>
|
|
17
|
+
<PersonOverview />
|
|
15
18
|
</View>
|
|
16
19
|
);
|
|
17
20
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describeComposePortFailure } from './integration/apiEnv';
|
|
2
|
+
|
|
3
|
+
describe('describeComposePortFailure', () => {
|
|
4
|
+
test('docker not installed', () => {
|
|
5
|
+
const error = Object.assign(new Error('spawnSync docker ENOENT'), { code: 'ENOENT' });
|
|
6
|
+
expect(describeComposePortFailure({ error, status: null })).toMatch(/^Docker is not installed/);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('docker compose unavailable', () => {
|
|
10
|
+
const message = describeComposePortFailure({
|
|
11
|
+
status: 1,
|
|
12
|
+
stderr: "docker: 'compose' is not a docker command.\nSee 'docker --help'",
|
|
13
|
+
});
|
|
14
|
+
expect(message).toMatch(/^Docker Compose is not available/);
|
|
15
|
+
expect(message).toContain('is not a docker command');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('non-zero exit mentioning compose', () => {
|
|
19
|
+
expect(describeComposePortFailure({ status: 125, stderr: 'unknown command: docker compose' })).toMatch(
|
|
20
|
+
/^Docker Compose is not available/,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('service not running', () => {
|
|
25
|
+
const message = describeComposePortFailure({ status: 1, stderr: 'service "fuseki" is not running' });
|
|
26
|
+
expect(message).toMatch(/Compose Fuseki is not running/);
|
|
27
|
+
expect(message).toContain('npm run fuseki:up');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('exit 0 without a port counts as not running', () => {
|
|
31
|
+
expect(describeComposePortFailure({ status: 0, stdout: '' })).toMatch(/Compose Fuseki is not running/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { apiUrl, resolveApiUrl } from '../src/shell/env';
|
|
2
|
+
|
|
3
|
+
// Explicit, so the module-level resolution does not depend on jest-expo's expo-constants mock.
|
|
4
|
+
jest.mock('expo-constants', () => ({
|
|
5
|
+
__esModule: true,
|
|
6
|
+
default: { expoConfig: { extra: { apiUrl: null, apiPort: 4000 }, hostUri: '192.168.1.5:8081' } },
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('resolveApiUrl', () => {
|
|
10
|
+
test('explicit url wins', () => {
|
|
11
|
+
expect(
|
|
12
|
+
resolveApiUrl({ extra: { apiUrl: 'http://api.test:9000', apiPort: 4000 }, hostUri: '192.168.1.5:8081' }),
|
|
13
|
+
).toBe('http://api.test:9000');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('derives from hostUri', () => {
|
|
17
|
+
expect(resolveApiUrl({ extra: { apiUrl: null, apiPort: 4000 }, hostUri: '192.168.1.5:8081' })).toBe(
|
|
18
|
+
'http://192.168.1.5:4000',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('ignores a non-string apiUrl (the dev-client manifest turns null into {})', () => {
|
|
23
|
+
expect(resolveApiUrl({ extra: { apiUrl: {}, apiPort: 4000 }, hostUri: '192.168.1.5:8081' })).toBe(
|
|
24
|
+
'http://192.168.1.5:4000',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('falls back to localhost', () => {
|
|
29
|
+
expect(resolveApiUrl({ extra: { apiUrl: null, apiPort: 4000 }, hostUri: undefined })).toBe(
|
|
30
|
+
'http://localhost:4000',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('sets process.env', () => {
|
|
35
|
+
expect(apiUrl).toBe('http://192.168.1.5:4000');
|
|
36
|
+
expect(process.env.SITE_ROOT).toBe(apiUrl);
|
|
37
|
+
expect(process.env.DATA_ROOT).toBe(`${apiUrl}/data`);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// Shared by globalSetup, globalTeardown and the tests: where the test API and Fuseki live.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const API_PORT = 4100;
|
|
7
|
+
export const API_URL = `http://localhost:${API_PORT}`;
|
|
8
|
+
// globalSetup deletes this dataset, so it must end in `-test` (checked there).
|
|
9
|
+
export const FUSEKI_DATASET = 'app-test';
|
|
10
|
+
|
|
11
|
+
export const repoRoot = resolve(__dirname, '../../../..');
|
|
12
|
+
export const apiDir = resolve(repoRoot, 'services/api');
|
|
13
|
+
|
|
14
|
+
/** Minimal KEY=VALUE parser for services/api/.env: no expansion, no multi-line values. */
|
|
15
|
+
export function parseEnvFile(text: string): Record<string, string> {
|
|
16
|
+
const vars: Record<string, string> = {};
|
|
17
|
+
for (const line of text.split(/\r?\n/)) {
|
|
18
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
19
|
+
if (!match) continue;
|
|
20
|
+
vars[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
21
|
+
}
|
|
22
|
+
return vars;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** services/api/.env as key/value pairs; empty when the file does not exist. */
|
|
26
|
+
export function readApiEnvFile(): Record<string, string> {
|
|
27
|
+
const envFile = resolve(apiDir, '.env');
|
|
28
|
+
return existsSync(envFile) ? parseEnvFile(readFileSync(envFile, 'utf8')) : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The parts of a `spawnSync('docker', ['compose', 'port', 'fuseki', '3030'])` result that explain a failure. */
|
|
32
|
+
export type ComposePortResult = {
|
|
33
|
+
error?: (Error & { code?: string }) | undefined;
|
|
34
|
+
status: number | null;
|
|
35
|
+
stdout?: string | null;
|
|
36
|
+
stderr?: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Why `docker compose port fuseki 3030` gave no port: Docker is not installed, Docker Compose is unavailable, or
|
|
41
|
+
* this repo's Compose Fuseki is not running.
|
|
42
|
+
*/
|
|
43
|
+
export function describeComposePortFailure(result: ComposePortResult): string {
|
|
44
|
+
const output = (result.stderr || result.error?.message || result.stdout || '').trim();
|
|
45
|
+
if (result.error?.code === 'ENOENT') {
|
|
46
|
+
return 'Docker is not installed or not on PATH (`docker` could not be started). Install Docker, then run `npm run fuseki:up`.';
|
|
47
|
+
}
|
|
48
|
+
const composeMissing =
|
|
49
|
+
/is not a docker command/i.test(output) ||
|
|
50
|
+
(result.status !== 0 && /compose/i.test(output) && !/not running|no container/i.test(output));
|
|
51
|
+
if (composeMissing) {
|
|
52
|
+
return `Docker Compose is not available (\`docker compose port fuseki 3030\`: ${output}). Install the Docker Compose plugin, then run \`npm run fuseki:up\`.`;
|
|
53
|
+
}
|
|
54
|
+
return `this repo's Compose Fuseki is not running (\`docker compose port fuseki 3030\`: ${output}). Run \`npm run fuseki:up\`.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type FusekiEnv ={ baseUrl: string; user: string; password: string };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The Fuseki the tests talk to. services/api/.env is the source, and the shell wins: FUSEKI_BASE_URL, or
|
|
61
|
+
* FUSEKI_PORT (the Compose host port) as a shorthand for localhost.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveFusekiEnv(env: NodeJS.ProcessEnv = process.env): FusekiEnv {
|
|
64
|
+
const file = readApiEnvFile();
|
|
65
|
+
const baseUrl =
|
|
66
|
+
env.FUSEKI_BASE_URL ||
|
|
67
|
+
(env.FUSEKI_PORT ? `http://localhost:${env.FUSEKI_PORT}` : '') ||
|
|
68
|
+
file.FUSEKI_BASE_URL ||
|
|
69
|
+
'http://localhost:3030';
|
|
70
|
+
return {
|
|
71
|
+
baseUrl: baseUrl.replace(/\/$/, ''),
|
|
72
|
+
user: env.FUSEKI_USER || file.FUSEKI_USER || 'admin',
|
|
73
|
+
password: env.FUSEKI_PASSWORD || file.FUSEKI_PASSWORD || 'admin',
|
|
74
|
+
};
|
|
75
|
+
}
|