@_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.
Files changed (68) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +78 -38
  3. package/defaults/app-react-native/README.md +62 -22
  4. package/defaults/app-react-native/apps/mobile/App.tsx +6 -3
  5. package/defaults/app-react-native/apps/mobile/__tests__/composePort.test.ts +33 -0
  6. package/defaults/app-react-native/apps/mobile/__tests__/env.test.ts +39 -0
  7. package/defaults/app-react-native/apps/mobile/__tests__/integration/apiEnv.ts +75 -0
  8. package/defaults/app-react-native/apps/mobile/__tests__/integration/globalSetup.ts +160 -0
  9. package/defaults/app-react-native/apps/mobile/__tests__/integration/globalTeardown.ts +20 -0
  10. package/defaults/app-react-native/apps/mobile/__tests__/integration/nodeFetchEnvironment.js +13 -0
  11. package/defaults/app-react-native/apps/mobile/__tests__/integration/personOverview.test.tsx +51 -0
  12. package/defaults/app-react-native/apps/mobile/__tests__/integration/restoreNodeFetch.ts +8 -0
  13. package/defaults/app-react-native/apps/mobile/__tests__/{linkedDefaults.test.tsx → nativeDefaults.test.tsx} +5 -7
  14. package/defaults/app-react-native/apps/mobile/__tests__/storage.test.ts +24 -0
  15. package/defaults/app-react-native/apps/mobile/__tests__/stripJsonImportAttributes.test.ts +45 -0
  16. package/defaults/app-react-native/apps/mobile/app.config.ts +21 -0
  17. package/defaults/app-react-native/apps/mobile/babel/stripJsonImportAttributes.js +41 -0
  18. package/defaults/app-react-native/apps/mobile/babel.config.js +13 -0
  19. package/defaults/app-react-native/apps/mobile/jest.integration.config.js +16 -0
  20. package/defaults/app-react-native/apps/mobile/package.json +11 -3
  21. package/defaults/app-react-native/apps/mobile/src/components/PersonOverview.tsx +123 -0
  22. package/defaults/app-react-native/apps/mobile/src/components/PersonOverviewContext.tsx +12 -0
  23. package/defaults/app-react-native/apps/mobile/src/components/PersonPreview.tsx +135 -0
  24. package/defaults/app-react-native/apps/mobile/src/shell/apiPort.json +1 -0
  25. package/defaults/app-react-native/apps/mobile/src/shell/env.ts +30 -0
  26. package/defaults/app-react-native/apps/mobile/src/shell/storage.ts +9 -0
  27. package/defaults/app-react-native/docker-compose.yml +22 -0
  28. package/defaults/app-react-native/eslint.config.js +47 -0
  29. package/defaults/app-react-native/gitignore.template +5 -0
  30. package/defaults/app-react-native/package.json +14 -4
  31. package/defaults/app-react-native/packages/app-shapes/package.json +17 -3
  32. package/defaults/app-react-native/packages/app-shapes/tsconfig-esm.json +12 -0
  33. package/defaults/app-react-native/scripts/check-react.mjs +55 -0
  34. package/defaults/app-react-native/services/api/README.md +50 -2
  35. package/defaults/app-react-native/services/api/env.example.template +11 -0
  36. package/defaults/app-react-native/services/api/linked.backend.datasets.json +15 -0
  37. package/defaults/app-react-native/services/api/linked.backend.storage.ts +35 -0
  38. package/defaults/app-react-native/services/api/package.json +27 -1
  39. package/defaults/app-react-native/services/api/scripts/wait-for-fuseki.mjs +26 -0
  40. package/defaults/app-react-native/services/api/src/backend.ts +9 -0
  41. package/defaults/app-react-native/services/api/src/fileStoreEnv.ts +14 -0
  42. package/defaults/app-react-native/services/api/test/hasS3Env.test.mjs +28 -0
  43. package/defaults/app-react-native/services/api/test/waitForFuseki.test.mjs +27 -0
  44. package/defaults/app-react-native/services/api/tsconfig.json +15 -0
  45. package/lib/esm/cli-methods.d.ts +7 -2
  46. package/lib/esm/cli-methods.d.ts.map +1 -1
  47. package/lib/esm/cli-methods.js +147 -104
  48. package/lib/esm/cli-methods.js.map +1 -1
  49. package/lib/esm/cli.js +24 -11
  50. package/lib/esm/cli.js.map +1 -1
  51. package/lib/esm/commands/start.d.ts.map +1 -1
  52. package/lib/esm/commands/start.js +12 -8
  53. package/lib/esm/commands/start.js.map +1 -1
  54. package/lib/esm/utils/esmSpecifiers.d.ts +12 -2
  55. package/lib/esm/utils/esmSpecifiers.d.ts.map +1 -1
  56. package/lib/esm/utils/esmSpecifiers.js +57 -14
  57. package/lib/esm/utils/esmSpecifiers.js.map +1 -1
  58. package/lib/esm/utils/shippedDotfiles.d.ts.map +1 -1
  59. package/lib/esm/utils/shippedDotfiles.js +3 -1
  60. package/lib/esm/utils/shippedDotfiles.js.map +1 -1
  61. package/lib/esm/utils.d.ts +16 -7
  62. package/lib/esm/utils.d.ts.map +1 -1
  63. package/lib/esm/utils.js +92 -40
  64. package/lib/esm/utils.js.map +1 -1
  65. package/lib/esm/vite-config.js +6 -6
  66. package/lib/esm/vite-config.js.map +1 -1
  67. package/package.json +1 -1
  68. 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-now/docs/blob/main/docs/backlog/016-ejection-export-flow.md) (canonical spec) + the symmetric backend/frontend split:
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 workspaces (enumerated) + overrides pinning @_linked/react's react to 19.2.3
137
- .gitignore node_modules/, packages/*, !packages/<prefix>-shapes/
138
- apps/mobile/ Expo app: app.json, App.tsx, index.ts, metro.config.js, jest config in package.json
139
- .gitignore includes /ios and /android
140
- src/shell/linkedDefaults.tsx React Native loader/error defaults for @_linked/react
141
- __tests__/ shapes.test.ts, linkedDefaults.test.tsx
142
- packages/<prefix>-shapes/ Linked shapes package, TypeScript source, extensionless imports, no build step
143
- services/api/ backend stub
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
- - Renames `gitignore.template` files to `.gitignore`. npm strips real `.gitignore` files from the CLI tarball.
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
- `com.formaestudios.formae`.
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
- `ios/` (and `android/`) are **generated, not committed**: `npx expo run:ios` (or `npx expo prebuild`) creates
161
- them from `app.json`, and `apps/mobile/.gitignore` ignores them.
189
+ The generated app:
162
190
 
163
- Quick commands in the generated app:
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 ls react # exactly one react@19.2.3
167
- npm test -w apps/mobile # Jest: shape registration + render defaults
168
- cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export # Metro bundle check
169
- cd apps/mobile && npx expo run:ios # build the dev client and run on the iOS Simulator
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
- Tests for this template in this repo: `yarn test:unit` covers the scaffold offline. It scaffolds the fixture in
173
- `tests/fixtures/app-react-native-min` and the real template. `npm run test:template` builds nothing itself. It
174
- runs the built CLI with install, then the three checks above. It needs network and takes several minutes.
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/ app shell, including the React Native render defaults for @_linked/react
9
- __tests__/ shape registration and render-default tests
10
- packages/app-shapes/ Linked shapes package, consumed as TypeScript source (no build step)
11
- services/api/ backend stub
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
- npm test -w apps/mobile # Jest
19
- cd apps/mobile && npx expo run:ios # generates ios/, builds a development build with expo-dev-client, starts Metro
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
- Requires Node `^22.13 || >=24`. iOS builds need macOS 26.2+ and Xcode 26.4+ with the iOS platform component
26
- installed.
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
- ## Quick checks
35
+ ## Checks
29
36
 
30
37
  | Command | What it proves |
31
38
  |---|---|
32
- | `npm ls react` (root) | exactly one React 19.2.3 in the tree |
33
- | `npm test -w apps/mobile` | shapes register at runtime; Linked components render React Native elements while loading and on error |
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.** `@_linked/react` still declares a React 18 peer, so the root `package.json` `overrides` pins its
39
- `react` to 19.2.3. Remove the override once the peer range is widened upstream. Do not use
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
- - **Render defaults.** `@_linked/react`'s built-in loader and error elements are `<svg>`, which crash on React
51
- Native. `apps/mobile/src/shell/linkedDefaults.tsx` replaces them app-wide and must be the first import of
52
- `App.tsx`. `__tests__/linkedDefaults.test.tsx` fails if the defaults are not applied.
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. For the same reason the package is never built with `linked build`,
55
- whose import check rejects extensionless imports.
56
- - **Decorators** work with the stock `babel-preset-expo`; no extra Babel plugin is needed.
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
- // Must stay the first import: replaces @_linked/react's <svg> loader and error defaults app-wide.
2
- import './src/shell/linkedDefaults';
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 { Example } from 'app-shapes';
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
+ }