@_linked/cli 1.18.0 → 1.19.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 (53) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +119 -0
  3. package/lib/esm/app-release/app-assets-store.d.ts +23 -0
  4. package/lib/esm/app-release/app-assets-store.d.ts.map +1 -0
  5. package/lib/esm/app-release/app-assets-store.js +33 -0
  6. package/lib/esm/app-release/app-assets-store.js.map +1 -0
  7. package/lib/esm/app-release/create-release-manifest.d.ts +114 -0
  8. package/lib/esm/app-release/create-release-manifest.d.ts.map +1 -0
  9. package/lib/esm/app-release/create-release-manifest.js +238 -0
  10. package/lib/esm/app-release/create-release-manifest.js.map +1 -0
  11. package/lib/esm/app-release/paths.d.ts +5 -0
  12. package/lib/esm/app-release/paths.d.ts.map +1 -0
  13. package/lib/esm/app-release/paths.js +44 -0
  14. package/lib/esm/app-release/paths.js.map +1 -0
  15. package/lib/esm/app-release/publisher.d.ts +25 -0
  16. package/lib/esm/app-release/publisher.d.ts.map +1 -0
  17. package/lib/esm/app-release/publisher.js +238 -0
  18. package/lib/esm/app-release/publisher.js.map +1 -0
  19. package/lib/esm/app-release/resolve-build-target.d.ts +19 -0
  20. package/lib/esm/app-release/resolve-build-target.d.ts.map +1 -0
  21. package/lib/esm/app-release/resolve-build-target.js +30 -0
  22. package/lib/esm/app-release/resolve-build-target.js.map +1 -0
  23. package/lib/esm/app-release/static-assets.d.ts +13 -0
  24. package/lib/esm/app-release/static-assets.d.ts.map +1 -0
  25. package/lib/esm/app-release/static-assets.js +55 -0
  26. package/lib/esm/app-release/static-assets.js.map +1 -0
  27. package/lib/esm/app-release/types.d.ts +71 -0
  28. package/lib/esm/app-release/types.d.ts.map +1 -0
  29. package/lib/esm/app-release/types.js +2 -0
  30. package/lib/esm/app-release/types.js.map +1 -0
  31. package/lib/esm/cli.js +78 -16
  32. package/lib/esm/cli.js.map +1 -1
  33. package/lib/esm/commands/build-app.d.ts +55 -0
  34. package/lib/esm/commands/build-app.d.ts.map +1 -0
  35. package/lib/esm/commands/build-app.js +178 -0
  36. package/lib/esm/commands/build-app.js.map +1 -0
  37. package/lib/esm/commands/publish-app.d.ts +18 -0
  38. package/lib/esm/commands/publish-app.d.ts.map +1 -0
  39. package/lib/esm/commands/publish-app.js +124 -0
  40. package/lib/esm/commands/publish-app.js.map +1 -0
  41. package/lib/esm/commands/serve-app.d.ts +14 -0
  42. package/lib/esm/commands/serve-app.d.ts.map +1 -0
  43. package/lib/esm/commands/serve-app.js +49 -0
  44. package/lib/esm/commands/serve-app.js.map +1 -0
  45. package/lib/esm/index.d.ts +11 -0
  46. package/lib/esm/index.d.ts.map +1 -1
  47. package/lib/esm/index.js +9 -0
  48. package/lib/esm/index.js.map +1 -1
  49. package/lib/esm/lifecycle.d.ts +1 -1
  50. package/lib/esm/lifecycle.d.ts.map +1 -1
  51. package/lib/esm/lifecycle.js +17 -8
  52. package/lib/esm/lifecycle.js.map +1 -1
  53. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#100](https://github.com/linked-fw/cli/pull/100) [`8ef0fd7`](https://github.com/linked-fw/cli/commit/8ef0fd7f810dfff26b95f5390565661b8c38c7ac) Thanks [@flyon](https://github.com/flyon)! - ## App release pipeline on the file-store API
8
+
9
+ A manifest-driven build/publish/serve flow for Linked apps, replacing the old recursive upload of
10
+ everything under `public/`.
11
+
12
+ ### New commands
13
+
14
+ ```bash
15
+ linked build-app --env <env> --target web # build + write public/bundles/linked-release.json
16
+ linked build-app --env <env> --publish # ...and upload it in the same run
17
+ linked build-app --env <env> --target capacitor # local, non-publishable manifest; never uploads
18
+ linked build-app --revision <sha> # identify the release outside a git checkout
19
+ linked build-app --allow-dirty # build uncommitted work as <sha>-dirty
20
+ linked publish-app --env <env> # dry run by default; pass --yes to upload
21
+ linked publish-app --manifest <path> # publish a manifest from another path
22
+ linked serve-app --env <env> # compiled runtime, no Vite/HMR
23
+ ```
24
+
25
+ `build-app` never uploads on its own — it writes a release manifest, and `publish-app` uploads
26
+ exactly the files that manifest lists.
27
+
28
+ ### Publishing destination
29
+
30
+ Uploads go through `LinkedFileStorage.getStore(FileStorePurposes.appAssets)`. An app that dedicates a
31
+ bundle store configures `setStore(FileStorePurposes.appAssets, store)`; an app with a single store
32
+ configures only `setDefaultStore(store)` and publishing reaches it through the purpose fallback, with
33
+ no extra configuration and no error.
34
+
35
+ ### Behaviour
36
+ - Object keys are `releases/<appVersion>-<revision>/<path>`, so releases no longer overwrite each
37
+ other. `publish.releasePrefix` in `linked.config.js` changes the base. A manifest may only upload
38
+ to its own prefix: each `objectKey` is recomputed from the prefix and source path before any
39
+ write, so an edited or foreign manifest cannot overwrite a different release, and source paths
40
+ that leave the app root (`../`, absolute, or through a symlink) are refused.
41
+ - The client bundle is built with `base = <accessURL>/<releasePrefix>/public/bundles/`, so the URLs
42
+ it emits for its own chunks and assets resolve under the release prefix on a plain static store
43
+ with no server rewrite. The value is recorded as `destination.baseURL`; the unused `publicRoot`
44
+ field is gone. The store is therefore resolved before Vite runs, and a release is built for one
45
+ store.
46
+ - The release revision comes from a clean `git rev-parse HEAD`. A dirty tree is refused (two builds
47
+ of the same uncommitted work would share a prefix and overwrite each other); `--allow-dirty`
48
+ builds as `<sha>-dirty`. `--revision <sha>`, `LINKED_RELEASE_REVISION` and `GITHUB_SHA` work
49
+ outside a Git checkout.
50
+ - Cache policy follows a file's origin, not its name: files in the Vite manifest are hashed by
51
+ construction and get `public, max-age=31536000, immutable`; declared static assets and the
52
+ release manifest get `public, max-age=60, must-revalidate`. The old filename heuristic gave
53
+ `og-image-1200x630.png` and `sw-v20260101.js` a year of immutable caching.
54
+ - `publish.staticAssets` globs are checked per match, not only per pattern, so brace expansion
55
+ (`public/{,../}secret/**`) and symlinks can no longer pull in files from outside `public/`.
56
+ - `APP_ENV` selects the Capacitor target only when it is literally `capacitor`. Any other value
57
+ (`production`, `staging`, …) builds web instead of silently producing an empty, non-publishable
58
+ manifest.
59
+ - Files are re-hashed locally against the manifest before upload. The post-upload check uses the
60
+ optional `IFileStore.statFile`; a store that lacks it, or reports no `sha256`, is warned about once
61
+ and skipped rather than failing. `etag` is never treated as a content hash.
62
+ - The manifest records the store's `accessURL` and publishing refuses a store that no longer matches.
63
+ - Objects are written with `preventDuplicates: false` and a store that reports a different location
64
+ than the key it was given fails the publish, rather than scattering renamed files.
65
+ - A failure partway through an upload names the file, the object key, the store and how many objects
66
+ were already uploaded, and says that re-running resumes the release.
67
+ - Standalone `publish-app` is a dry run unless `--yes`; only publishable `target: 'web'` manifests
68
+ upload; the manifest is read before a store is resolved, so a Capacitor manifest fails with a
69
+ manifest error rather than a storage error; nothing is ever deleted remotely; credentials are
70
+ redacted from publisher errors.
71
+ - `build-app`, `publish-app` and `serve-app` print a single error and exit 1 instead of surfacing an
72
+ unhandled rejection, and `--target`/`--publish`/`--revision`/`--allow-dirty` are rejected on the
73
+ legacy webpack path instead of being ignored.
74
+
75
+ ### New exports
76
+
77
+ From `@_linked/cli`: `buildViteApp`, `hasViteConfig`, `publishApp`, `serveCompiledApp`,
78
+ `validateCompiledAppArtifacts`, `createReleaseManifest`, `serializeReleaseManifest`,
79
+ `writeReleaseManifest`, `buildReleasePrefix`, `releaseObjectKey`, `getCacheControl`,
80
+ `planReleasePublish`, `publishRelease`, `loadReleaseManifest`, `resolveAppAssetsStore`,
81
+ `normalizeAccessURL`, `resolveBuildTarget`, `resolveDeclaredStaticAssets`, `resolveReleaseIdentity`,
82
+ `resolveSourceRevision`, `releaseBaseURL`, `VITE_OUTPUT_DIR`, the path helpers, and the release
83
+ types.
84
+
85
+ Apps must build with `build.outDir: 'public/bundles'` and `build.manifest: true` (both already set
86
+ by `createViteConfig`) and must not set `base` themselves; see the README.
87
+
88
+ Requires `@_linked/core` 2.20.1 for the purpose-named file stores and `IFileStore.statFile`.
89
+
3
90
  ## 1.18.0
4
91
 
5
92
  ### Minor Changes
package/README.md CHANGED
@@ -34,6 +34,8 @@ linked create-component <name> # add a React component file
34
34
  ```bash
35
35
  linked build # build the current package (tsc + checks)
36
36
  linked build-app # build frontend + backend for the current app
37
+ linked build-app --target web # web release: build and write public/bundles/linked-release.json
38
+ linked build-app --target capacitor # Capacitor/local native build: local manifest only, never published
37
39
  linked build-workspace # build all linked packages in the workspace in dependency order
38
40
  linked build-updated # incremental: only packages that changed since last build
39
41
  linked build-package <file> # walk up from a file path to find its package and rebuild
@@ -43,6 +45,123 @@ linked build-package <file> # walk up from a file path to find its package
43
45
  in a package without `"linkedPackage": true` (it used to exit 0 there). A build that finishes with warnings still
44
46
  exits 0.
45
47
 
48
+ ### App release / production runtime
49
+
50
+ ```bash
51
+ linked build-app # build and write the release manifest (never uploads)
52
+ linked build-app --publish # ...and upload it in the same run
53
+ linked build-app --revision <sha> # identify the release without asking git
54
+ linked build-app --allow-dirty # allow uncommitted changes; revision gets a "-dirty" suffix
55
+ linked publish-app # dry-run the release manifest upload (pass --yes to write)
56
+ linked publish-app --yes # upload the release described by the manifest
57
+ linked publish-app --manifest <p> # publish a manifest other than the default path
58
+ linked serve-app # run the compiled backend without Vite/HMR (production runtime)
59
+ ```
60
+
61
+ **Required Vite config.** The release flow reads `vite build` output directly, and the paths are
62
+ hardcoded: the client bundle must land in `public/bundles` and Vite must write its manifest.
63
+ `createViteConfig()` from `@_linked/cli/vite` already sets both; a hand-written `vite.config.ts`
64
+ needs them explicitly:
65
+
66
+ ```ts
67
+ export default defineConfig({
68
+ build: {
69
+ outDir: 'public/bundles', // release object keys are <prefix>/public/bundles/...
70
+ manifest: true, // build-app reads public/bundles/.vite/manifest.json
71
+ },
72
+ });
73
+ ```
74
+
75
+ Without `manifest: true` the build fails with a missing `public/bundles/.vite/manifest.json`; with a
76
+ different `outDir` nothing is found to publish. Do not set `base` yourself — `build-app` passes it
77
+ (see "How a release is served").
78
+
79
+ **Building is not publishing.** `build-app` compiles the app and writes
80
+ `public/bundles/linked-release.json`, a manifest listing every file of the release with its sha256,
81
+ size, content type and cache policy. `publish-app` uploads exactly the files in that manifest —
82
+ nothing else in `public/` is touched. Pass `--publish` to `build-app` only when you want the two
83
+ chained in one command. `publish-app` is a dry run unless `--yes` is given, and `--manifest <path>`
84
+ publishes a manifest from somewhere other than `public/bundles/linked-release.json` (the path is
85
+ relative to the app root and must stay inside it).
86
+
87
+ **Where it publishes.** Uploads go to the store returned by
88
+ `LinkedFileStorage.getStore(FileStorePurposes.appAssets)`. Configure a dedicated bundle store with
89
+ `LinkedFileStorage.setStore(FileStorePurposes.appAssets, store)`; an app that configures only
90
+ `setDefaultStore(store)` publishes to that store through the purpose fallback, which needs no extra
91
+ configuration.
92
+
93
+ **Every release gets its own prefix.** Object keys are
94
+ `releases/<appVersion>-<revision>/<path>`, so publishing a new release cannot overwrite the
95
+ previous one and a rollback means pointing at an older prefix. Set `publish.releasePrefix` in
96
+ `linked.config.js` to change the `releases` base. A manifest may only upload to its own prefix: every
97
+ `objectKey` is recomputed from the prefix and the source path before anything is written, so an
98
+ edited or foreign manifest cannot reach into another release.
99
+
100
+ **How a release is served.** Because every release has its own key prefix, the bundle's own URLs have
101
+ to carry that prefix — nothing rewrites them at request time. `build-app` therefore resolves the
102
+ store and the release prefix *before* running Vite and passes
103
+
104
+ ```
105
+ base = <store accessURL>/<releasePrefix>/public/bundles/
106
+ ```
107
+
108
+ so the chunk and asset URLs the bundle emits are absolute URLs into the release prefix. This works on
109
+ a plain static store or CDN with no rewrite rules, and two releases can be served side by side. The
110
+ value is recorded as `destination.baseURL` in the release manifest.
111
+
112
+ The HTML entry tags are not part of the bundle — `@_linked/server` renders them as
113
+ `${STATIC_ACCESS_URL}/public/bundles/<file>`. So serving a particular release is one deployment
114
+ variable, which `build-app` prints when it finishes:
115
+
116
+ ```
117
+ STATIC_ACCESS_URL=<store accessURL>/releases/<appVersion>-<revision>
118
+ ```
119
+
120
+ Set that on the app server (or roll back by setting it to an older release prefix) and the entry
121
+ tags and everything the bundle loads resolve to the same release. Note the consequence of baking the
122
+ base in: a release is built for one store, and pointing it at another means rebuilding.
123
+
124
+ **Which revision identifies a release.** By default the short `git rev-parse HEAD` of a **clean**
125
+ tree. A dirty tree is refused, because two builds of the same uncommitted work would share a release
126
+ ID and prefix and silently overwrite each other while removed files linger; pass `--allow-dirty` to
127
+ build anyway as `<sha>-dirty`. Outside a Git checkout (an exported tarball, some CI images) pass
128
+ `--revision <sha>`, or set `LINKED_RELEASE_REVISION` or `GITHUB_SHA`; those win over Git and skip
129
+ the clean-tree check.
130
+
131
+ **Verification.** Files are re-hashed from disk and compared against the manifest immediately before
132
+ upload, so a build that changed since the manifest was written is refused. After upload the store is
133
+ asked for `statFile`; that method is optional and may report no `sha256`, in which case the remote
134
+ check is skipped with a single warning rather than a failure. An `etag` is never used as a content
135
+ hash. The manifest also records the store's `accessURL`, and publishing refuses to run against a
136
+ store that now writes somewhere else. A failure partway through names the file, the object key, the
137
+ store and how many objects were already uploaded; the manifest is uploaded last, so an incomplete
138
+ release is never marked complete, and re-running `publish-app` resumes it.
139
+
140
+ **The store must keep keys verbatim.** A release object has to be stored under exactly the key it
141
+ was given, or the URLs baked into the bundle point at nothing, so publishing passes
142
+ `preventDuplicates: false` and fails with a clear message if the store reports a different location.
143
+ `@_linked/server`'s `LocalFileStore` lowercases the keys it is handed, so an app publishing to it
144
+ needs lowercase asset filenames (`rollupOptions.output.hashCharacters: 'hex'` in `vite.config`).
145
+
146
+ **Cache policy follows the origin, not the filename.** Files listed in the Vite manifest are
147
+ content-hashed by construction and get `public, max-age=31536000, immutable`. Everything else —
148
+ declared `publish.staticAssets` and the release manifest — gets
149
+ `public, max-age=60, must-revalidate`. (Guessing from the filename was tried and dropped:
150
+ `og-image-1200x630.png` and `sw-v20260101.js` read as hashed, and a year of immutable caching on
151
+ those cannot be undone without renaming the file.)
152
+
153
+ **Extra static assets.** `publish.staticAssets` in `linked.config.js` lists globs of files under
154
+ `public/` to ship besides the bundle. Both the pattern and every match must resolve inside
155
+ `public/`, so brace expansion and symlinks cannot pull in files from elsewhere in the repo.
156
+
157
+ **Capacitor.** `--target capacitor` (or `APP_ENV=capacitor`, matched literally) writes a local,
158
+ non-publishable manifest for inspection. Its assets ship inside the native app, so no file store is
159
+ resolved, no `base` is set and nothing is uploaded, even with `--publish`. Any other `APP_ENV` value
160
+ (`production`, `staging`, …) is ignored by the target choice and builds web.
161
+
162
+ Use `linked start` for development; use `linked serve-app` after `linked build-app` for
163
+ staging/production.
164
+
46
165
  ### Publishing / release
47
166
 
48
167
  ```bash
@@ -0,0 +1,23 @@
1
+ import type { IFileStore } from '@_linked/core/interfaces/IFileStore';
2
+ export interface ResolveAppAssetsStoreDependencies {
3
+ /**
4
+ * Loads the app's storage bootstrap file. Importing it is what registers the
5
+ * stores on `LinkedFileStorage`; the module's own exports are not inspected.
6
+ */
7
+ loadStorageConfig?: () => Promise<unknown>;
8
+ /** Injection point for tests. Defaults to `LinkedFileStorage.getStore`. */
9
+ getStore?: (purpose: string) => IFileStore;
10
+ }
11
+ /**
12
+ * Resolve the store that release artifacts are published to.
13
+ *
14
+ * Apps that dedicate a CDN bucket to bundles configure
15
+ * `LinkedFileStorage.setStore(FileStorePurposes.appAssets, …)`. Apps with a
16
+ * single store configure only a default store and `getStore` falls back to it
17
+ * — that is a supported setup, not an error, so there is no check for a
18
+ * dedicated store here.
19
+ */
20
+ export declare const resolveAppAssetsStore: (dependencies?: ResolveAppAssetsStoreDependencies) => Promise<IFileStore>;
21
+ /** `accessURL` values are compared ignoring a trailing slash. */
22
+ export declare const normalizeAccessURL: (accessURL: string) => string;
23
+ //# sourceMappingURL=app-assets-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-assets-store.d.ts","sourceRoot":"","sources":["../../../src/app-release/app-assets-store.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,qCAAqC,CAAC;AAEpE,MAAM,WAAW,iCAAiC;IAChD;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,UAAU,CAAC;CAC5C;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,GAChC,eAAc,iCAAsC,KACnD,OAAO,CAAC,UAAU,CAapB,CAAC;AAEF,iEAAiE;AACjE,eAAO,MAAM,kBAAkB,GAAI,WAAW,MAAM,KAAG,MACT,CAAC"}
@@ -0,0 +1,33 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { FileStorePurposes, LinkedFileStorage, } from '@_linked/core/utils/LinkedFileStorage';
11
+ /**
12
+ * Resolve the store that release artifacts are published to.
13
+ *
14
+ * Apps that dedicate a CDN bucket to bundles configure
15
+ * `LinkedFileStorage.setStore(FileStorePurposes.appAssets, …)`. Apps with a
16
+ * single store configure only a default store and `getStore` falls back to it
17
+ * — that is a supported setup, not an error, so there is no check for a
18
+ * dedicated store here.
19
+ */
20
+ export const resolveAppAssetsStore = (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (dependencies = {}) {
21
+ const loadStorageConfig = dependencies.loadStorageConfig ||
22
+ (() => __awaiter(void 0, void 0, void 0, function* () {
23
+ const { loadBackendStorageConfig } = yield import('../lifecycle.js');
24
+ return loadBackendStorageConfig();
25
+ }));
26
+ yield loadStorageConfig();
27
+ const getStore = dependencies.getStore ||
28
+ ((purpose) => LinkedFileStorage.getStore(purpose));
29
+ return getStore(FileStorePurposes.appAssets);
30
+ });
31
+ /** `accessURL` values are compared ignoring a trailing slash. */
32
+ export const normalizeAccessURL = (accessURL) => (accessURL || '').trim().replace(/\/+$/, '');
33
+ //# sourceMappingURL=app-assets-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-assets-store.js","sourceRoot":"","sources":["../../../src/app-release/app-assets-store.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EACL,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,uCAAuC,CAAC;AAa/C;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,YAEd,EAAE,mDADvB,eAAkD,EAAE;IAEpD,MAAM,iBAAiB,GACrB,YAAY,CAAC,iBAAiB;QAC9B,CAAC,GAAS,EAAE;YACV,MAAM,EAAC,wBAAwB,EAAC,GAAG,MAAM,MAAM,CAAC,iBAAiB,CAAC,CAAC;YACnE,OAAO,wBAAwB,EAAE,CAAC;QACpC,CAAC,CAAA,CAAC,CAAC;IACL,MAAM,iBAAiB,EAAE,CAAC;IAE1B,MAAM,QAAQ,GACZ,YAAY,CAAC,QAAQ;QACrB,CAAC,CAAC,OAAe,EAAE,EAAE,CAAC,iBAAiB,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IAC7D,OAAO,QAAQ,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;AAC/C,CAAC,CAAA,CAAC;AAEF,iEAAiE;AACjE,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,SAAiB,EAAU,EAAE,CAC9D,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC"}
@@ -0,0 +1,114 @@
1
+ import type { AppBuildTarget, LinkedAppReleaseManifest } from './types.js';
2
+ export interface CreateReleaseManifestOptions {
3
+ appRoot: string;
4
+ environmentNames: string[];
5
+ target: AppBuildTarget;
6
+ /**
7
+ * `accessURL` of the store this release will be published to. Omitted (or
8
+ * null) for a Capacitor build, which produces a local, non-publishable
9
+ * manifest and never touches a file store.
10
+ */
11
+ accessURL?: string | null;
12
+ staticAssets?: string[];
13
+ /** Base key releases are nested under. Defaults to `releases`. */
14
+ releasePrefix?: string;
15
+ builtAt?: string;
16
+ sourceRevision?: string;
17
+ /** Allow a revision derived from a working tree with uncommitted changes. */
18
+ allowDirty?: boolean;
19
+ /**
20
+ * Public URL every asset of this release is served from — the release
21
+ * prefix resolved against the store's `accessURL`, and the value the client
22
+ * bundle was built with as Vite's `base`. Empty for a Capacitor build.
23
+ */
24
+ baseURL?: string;
25
+ }
26
+ export declare const DEFAULT_RELEASE_PREFIX = "releases";
27
+ export declare const MANIFEST_FILE_NAME = "linked-release.json";
28
+ export declare const MANIFEST_SOURCE_PATH = "public/bundles/linked-release.json";
29
+ export declare const MANIFEST_CONTENT_TYPE = "application/json; charset=utf-8";
30
+ /**
31
+ * Cache-Control rule, decided by where a file came from rather than by what it
32
+ * is called.
33
+ *
34
+ * Files listed in the Vite manifest are content-hashed by construction — Vite
35
+ * emitted the name, so the bytes behind that key can never change and the
36
+ * object is immutable for a year. Everything else (declared static assets, the
37
+ * release manifest) is revalidated after a minute, so a re-published release is
38
+ * picked up quickly. Guessing "is this hashed?" from the filename was tried and
39
+ * removed: `og-image-1200x630.png`, `sw-v20260101.js` and `icon-FacebookRound.svg`
40
+ * all read as hashed, and a year of immutable caching on those cannot be undone
41
+ * without renaming the file.
42
+ */
43
+ export declare const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
44
+ export declare const ENTRY_CACHE_CONTROL = "public, max-age=60, must-revalidate";
45
+ /** Where a release file came from; this is what decides its cache policy. */
46
+ export type ReleaseFileOrigin = 'vite-bundle' | 'static-asset';
47
+ export declare const getCacheControl: (origin: ReleaseFileOrigin) => string;
48
+ export interface ResolveSourceRevisionOptions {
49
+ appRoot: string;
50
+ /** `--revision`, or a caller-supplied value. Used verbatim when given. */
51
+ revision?: string;
52
+ /** `--allow-dirty`: build from a dirty tree, tagging the revision `-dirty`. */
53
+ allowDirty?: boolean;
54
+ env?: NodeJS.ProcessEnv;
55
+ }
56
+ /**
57
+ * Determine the revision that identifies this release.
58
+ *
59
+ * An explicit `--revision`, then `LINKED_RELEASE_REVISION`, then `GITHUB_SHA`
60
+ * win over Git, so a release can be built from an exported tarball or a CI
61
+ * checkout that is not a working repository. Falling back to `git rev-parse`
62
+ * requires a clean tree: two builds of the same uncommitted work would
63
+ * otherwise share a release ID and a prefix, silently overwriting each other
64
+ * while files removed in between linger under the prefix.
65
+ */
66
+ export declare const resolveSourceRevision: ({ appRoot, revision, allowDirty, env, }: ResolveSourceRevisionOptions) => string;
67
+ /**
68
+ * Build the key prefix for one release. Every object of a release lives under
69
+ * it, so publishing a new release cannot overwrite the previous one and a
70
+ * rollback is a matter of pointing at the older prefix.
71
+ */
72
+ export declare const buildReleasePrefix: (releaseId: string, basePrefix?: string) => string;
73
+ export declare const releaseObjectKey: (releasePrefix: string, sourcePath: string) => string;
74
+ /** Directory `vite build` writes the client bundle to. See the README. */
75
+ export declare const VITE_OUTPUT_DIR = "public/bundles";
76
+ /**
77
+ * Public URL the client bundle of a release is served from. Object keys are
78
+ * `<releasePrefix>/public/bundles/…` under the store's `accessURL`, and this is
79
+ * the value the build passes to Vite as `base`, so the root-absolute URLs the
80
+ * bundle emits for its own chunks and assets resolve under the release prefix
81
+ * without any server rewrite.
82
+ */
83
+ export declare const releaseBaseURL: (accessURL: string, releasePrefix: string) => string;
84
+ /**
85
+ * The release's root URL: `accessURL` plus the release prefix, without the
86
+ * bundle directory. `@_linked/server` builds the HTML entry tags as
87
+ * `${STATIC_ACCESS_URL}/public/bundles/<file>`, so pointing `STATIC_ACCESS_URL`
88
+ * at this value is what makes a running app serve one particular release —
89
+ * a value to deploy, not server code to write or a rewrite rule to configure.
90
+ */
91
+ export declare const releaseStaticAccessURL: (accessURL: string, releasePrefix: string) => string;
92
+ export interface ReleaseIdentity {
93
+ appName: string;
94
+ appVersion: string;
95
+ sourceRevision: string;
96
+ releaseId: string;
97
+ releasePrefix: string;
98
+ }
99
+ /**
100
+ * Work out the release ID and prefix before anything is built. The client
101
+ * bundle has to be built with the release prefix as its `base`, so the prefix
102
+ * cannot wait until the manifest is written.
103
+ */
104
+ export declare const resolveReleaseIdentity: (options: {
105
+ appRoot: string;
106
+ releasePrefix?: string;
107
+ sourceRevision?: string;
108
+ allowDirty?: boolean;
109
+ env?: NodeJS.ProcessEnv;
110
+ }) => ReleaseIdentity;
111
+ export declare const createReleaseManifest: (options: CreateReleaseManifestOptions) => LinkedAppReleaseManifest;
112
+ export declare const serializeReleaseManifest: (manifest: LinkedAppReleaseManifest) => string;
113
+ export declare const writeReleaseManifest: (appRoot: string, manifest: LinkedAppReleaseManifest) => string;
114
+ //# sourceMappingURL=create-release-manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-release-manifest.d.ts","sourceRoot":"","sources":["../../../src/app-release/create-release-manifest.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EACV,cAAc,EAEd,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAYpB,MAAM,WAAW,4BAA4B;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,MAAM,EAAE,cAAc,CAAC;IACvB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,kEAAkE;IAClE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAmBD,eAAO,MAAM,sBAAsB,aAAa,CAAC;AACjD,eAAO,MAAM,kBAAkB,wBAAwB,CAAC;AACxD,eAAO,MAAM,oBAAoB,uCAAyC,CAAC;AAC3E,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAEvE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,wCAAwC,CAAC;AAC7E,eAAO,MAAM,mBAAmB,wCAAwC,CAAC;AAEzE,6EAA6E;AAC7E,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG,cAAc,CAAC;AAK/D,eAAO,MAAM,eAAe,GAAI,QAAQ,iBAAiB,KAAG,MACc,CAAC;AAY3E,MAAM,WAAW,4BAA4B;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,GAAI,yCAKnC,4BAA4B,KAAG,MA2CjC,CAAC;AA8BF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAC7B,WAAW,MAAM,EACjB,aAAY,MAA+B,KAC1C,MAA8C,CAAC;AAElD,eAAO,MAAM,gBAAgB,GAC3B,eAAe,MAAM,EACrB,YAAY,MAAM,KACjB,MAAkD,CAAC;AAEtD,0EAA0E;AAC1E,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAEhD;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GACzB,WAAW,MAAM,EACjB,eAAe,MAAM,KACpB,MACwE,CAAC;AAE5E;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,GACjC,WAAW,MAAM,EACjB,eAAe,MAAM,KACpB,MACiE,CAAC;AAErE,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,GAAI,SAAS;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB,KAAG,eAkBH,CAAC;AAsBF,eAAO,MAAM,qBAAqB,GAChC,SAAS,4BAA4B,KACpC,wBAoEF,CAAC;AAEF,eAAO,MAAM,wBAAwB,GACnC,UAAU,wBAAwB,KACjC,MAAkD,CAAC;AAEtD,eAAO,MAAM,oBAAoB,GAC/B,SAAS,MAAM,EACf,UAAU,wBAAwB,KACjC,MAIF,CAAC"}
@@ -0,0 +1,238 @@
1
+ import crypto from 'crypto';
2
+ import { execFileSync } from 'child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { joinObjectKey, normalizeReleasePath, resolveExistingPathWithinRoot, } from './paths.js';
6
+ import { resolveDeclaredStaticAssets } from './static-assets.js';
7
+ const MIME_TYPES = {
8
+ '.css': 'text/css; charset=utf-8',
9
+ '.gif': 'image/gif',
10
+ '.html': 'text/html; charset=utf-8',
11
+ '.ico': 'image/x-icon',
12
+ '.jpeg': 'image/jpeg',
13
+ '.jpg': 'image/jpeg',
14
+ '.js': 'text/javascript; charset=utf-8',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.map': 'application/json; charset=utf-8',
17
+ '.png': 'image/png',
18
+ '.svg': 'image/svg+xml',
19
+ '.webp': 'image/webp',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2',
22
+ };
23
+ export const DEFAULT_RELEASE_PREFIX = 'releases';
24
+ export const MANIFEST_FILE_NAME = 'linked-release.json';
25
+ export const MANIFEST_SOURCE_PATH = `public/bundles/${MANIFEST_FILE_NAME}`;
26
+ export const MANIFEST_CONTENT_TYPE = 'application/json; charset=utf-8';
27
+ /**
28
+ * Cache-Control rule, decided by where a file came from rather than by what it
29
+ * is called.
30
+ *
31
+ * Files listed in the Vite manifest are content-hashed by construction — Vite
32
+ * emitted the name, so the bytes behind that key can never change and the
33
+ * object is immutable for a year. Everything else (declared static assets, the
34
+ * release manifest) is revalidated after a minute, so a re-published release is
35
+ * picked up quickly. Guessing "is this hashed?" from the filename was tried and
36
+ * removed: `og-image-1200x630.png`, `sw-v20260101.js` and `icon-FacebookRound.svg`
37
+ * all read as hashed, and a year of immutable caching on those cannot be undone
38
+ * without renaming the file.
39
+ */
40
+ export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
41
+ export const ENTRY_CACHE_CONTROL = 'public, max-age=60, must-revalidate';
42
+ const getContentType = (filePath) => MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
43
+ export const getCacheControl = (origin) => origin === 'vite-bundle' ? IMMUTABLE_CACHE_CONTROL : ENTRY_CACHE_CONTROL;
44
+ /** A revision has to be safe to embed in an object key and a URL. */
45
+ const SAFE_REVISION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
46
+ const runGit = (appRoot, args) => execFileSync('git', args, {
47
+ cwd: appRoot,
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ });
51
+ /**
52
+ * Determine the revision that identifies this release.
53
+ *
54
+ * An explicit `--revision`, then `LINKED_RELEASE_REVISION`, then `GITHUB_SHA`
55
+ * win over Git, so a release can be built from an exported tarball or a CI
56
+ * checkout that is not a working repository. Falling back to `git rev-parse`
57
+ * requires a clean tree: two builds of the same uncommitted work would
58
+ * otherwise share a release ID and a prefix, silently overwriting each other
59
+ * while files removed in between linger under the prefix.
60
+ */
61
+ export const resolveSourceRevision = ({ appRoot, revision, allowDirty, env = process.env, }) => {
62
+ const explicit = (revision ||
63
+ env.LINKED_RELEASE_REVISION ||
64
+ env.GITHUB_SHA ||
65
+ '').trim();
66
+ if (explicit) {
67
+ if (!SAFE_REVISION.test(explicit)) {
68
+ throw new Error(`Invalid release revision "${explicit}". Use letters, digits, ".", "-" or "_".`);
69
+ }
70
+ return explicit;
71
+ }
72
+ let head;
73
+ try {
74
+ head = runGit(appRoot, ['rev-parse', '--short=12', 'HEAD']).trim();
75
+ }
76
+ catch (_a) {
77
+ throw new Error('Cannot determine the Git revision for this release. Build from a Git checkout, ' +
78
+ 'or pass --revision <sha> (or set LINKED_RELEASE_REVISION / GITHUB_SHA).');
79
+ }
80
+ let dirty = false;
81
+ try {
82
+ dirty = runGit(appRoot, ['status', '--porcelain']).trim().length > 0;
83
+ }
84
+ catch (_b) {
85
+ // `rev-parse` worked, so this is a repository; treat an unreadable status
86
+ // as clean rather than blocking the build on a Git quirk.
87
+ }
88
+ if (!dirty)
89
+ return head;
90
+ if (!allowDirty) {
91
+ throw new Error(`The working tree has uncommitted changes, so release ${head} would not identify ` +
92
+ 'what was built: a second build of the same edits reuses the prefix and overwrites it. ' +
93
+ 'Commit or stash the changes, pass --allow-dirty to publish as ' +
94
+ `"${head}-dirty", or pass --revision <sha>.`);
95
+ }
96
+ return `${head}-dirty`;
97
+ };
98
+ const collectViteOutputPaths = (manifest) => {
99
+ const files = new Set();
100
+ for (const [entryName, entry] of Object.entries(manifest)) {
101
+ if (!entry || typeof entry.file !== 'string') {
102
+ throw new Error(`Invalid Vite manifest entry: ${entryName}`);
103
+ }
104
+ files.add(joinObjectKey('public/bundles', entry.file));
105
+ for (const relativePath of [...(entry.css || []), ...(entry.assets || [])]) {
106
+ files.add(joinObjectKey('public/bundles', relativePath));
107
+ }
108
+ for (const importedEntry of [
109
+ ...(entry.imports || []),
110
+ ...(entry.dynamicImports || []),
111
+ ]) {
112
+ if (!manifest[importedEntry]) {
113
+ throw new Error(`Vite manifest entry ${entryName} references missing import ${importedEntry}`);
114
+ }
115
+ }
116
+ }
117
+ return [...files];
118
+ };
119
+ /**
120
+ * Build the key prefix for one release. Every object of a release lives under
121
+ * it, so publishing a new release cannot overwrite the previous one and a
122
+ * rollback is a matter of pointing at the older prefix.
123
+ */
124
+ export const buildReleasePrefix = (releaseId, basePrefix = DEFAULT_RELEASE_PREFIX) => joinObjectKey(basePrefix, releaseId);
125
+ export const releaseObjectKey = (releasePrefix, sourcePath) => joinObjectKey(releasePrefix, sourcePath);
126
+ /** Directory `vite build` writes the client bundle to. See the README. */
127
+ export const VITE_OUTPUT_DIR = 'public/bundles';
128
+ /**
129
+ * Public URL the client bundle of a release is served from. Object keys are
130
+ * `<releasePrefix>/public/bundles/…` under the store's `accessURL`, and this is
131
+ * the value the build passes to Vite as `base`, so the root-absolute URLs the
132
+ * bundle emits for its own chunks and assets resolve under the release prefix
133
+ * without any server rewrite.
134
+ */
135
+ export const releaseBaseURL = (accessURL, releasePrefix) => `${releaseStaticAccessURL(accessURL, releasePrefix)}/${VITE_OUTPUT_DIR}/`;
136
+ /**
137
+ * The release's root URL: `accessURL` plus the release prefix, without the
138
+ * bundle directory. `@_linked/server` builds the HTML entry tags as
139
+ * `${STATIC_ACCESS_URL}/public/bundles/<file>`, so pointing `STATIC_ACCESS_URL`
140
+ * at this value is what makes a running app serve one particular release —
141
+ * a value to deploy, not server code to write or a rewrite rule to configure.
142
+ */
143
+ export const releaseStaticAccessURL = (accessURL, releasePrefix) => `${(accessURL || '').trim().replace(/\/+$/, '')}/${releasePrefix}`;
144
+ /**
145
+ * Work out the release ID and prefix before anything is built. The client
146
+ * bundle has to be built with the release prefix as its `base`, so the prefix
147
+ * cannot wait until the manifest is written.
148
+ */
149
+ export const resolveReleaseIdentity = (options) => {
150
+ const packageJson = JSON.parse(fs.readFileSync(path.join(options.appRoot, 'package.json'), 'utf8'));
151
+ const sourceRevision = resolveSourceRevision({
152
+ appRoot: options.appRoot,
153
+ revision: options.sourceRevision,
154
+ allowDirty: options.allowDirty,
155
+ env: options.env,
156
+ });
157
+ const releaseId = `${packageJson.version}-${sourceRevision}`;
158
+ return {
159
+ appName: packageJson.name,
160
+ appVersion: packageJson.version,
161
+ sourceRevision,
162
+ releaseId,
163
+ releasePrefix: buildReleasePrefix(releaseId, options.releasePrefix),
164
+ };
165
+ };
166
+ const describeReleaseFile = (appRoot, releasePrefix, sourcePath, origin) => {
167
+ const normalizedSourcePath = normalizeReleasePath(sourcePath, 'source path');
168
+ const absolutePath = resolveExistingPathWithinRoot(appRoot, normalizedSourcePath);
169
+ const content = fs.readFileSync(absolutePath);
170
+ return {
171
+ sourcePath: normalizedSourcePath,
172
+ objectKey: releaseObjectKey(releasePrefix, normalizedSourcePath),
173
+ sha256: crypto.createHash('sha256').update(content).digest('hex'),
174
+ size: content.byteLength,
175
+ contentType: getContentType(normalizedSourcePath),
176
+ cacheControl: getCacheControl(origin),
177
+ };
178
+ };
179
+ export const createReleaseManifest = (options) => {
180
+ var _a;
181
+ const identity = resolveReleaseIdentity({
182
+ appRoot: options.appRoot,
183
+ releasePrefix: options.releasePrefix,
184
+ sourceRevision: options.sourceRevision,
185
+ allowDirty: options.allowDirty,
186
+ });
187
+ const { releasePrefix } = identity;
188
+ // A Capacitor build ships its assets inside the native app, so it produces a
189
+ // manifest for local inspection only and is never uploaded. That is why it
190
+ // needs no destination and is marked unpublishable.
191
+ const publishable = options.target === 'web';
192
+ if (publishable && options.accessURL === undefined) {
193
+ throw new Error('A web release manifest requires the accessURL of the store it will be published to');
194
+ }
195
+ // Origin decides the cache policy: Vite named these files after their own
196
+ // content, so they are immutable; declared static assets keep their name
197
+ // across releases and must be revalidated.
198
+ const origins = new Map();
199
+ if (options.target === 'web') {
200
+ const viteManifestPath = path.join(options.appRoot, `${VITE_OUTPUT_DIR}/.vite/manifest.json`);
201
+ const viteManifest = JSON.parse(fs.readFileSync(viteManifestPath, 'utf8'));
202
+ for (const sourcePath of collectViteOutputPaths(viteManifest)) {
203
+ origins.set(sourcePath, 'vite-bundle');
204
+ }
205
+ }
206
+ for (const sourcePath of resolveDeclaredStaticAssets(options.appRoot, options.staticAssets)) {
207
+ if (!origins.has(sourcePath))
208
+ origins.set(sourcePath, 'static-asset');
209
+ }
210
+ const files = [...origins.entries()]
211
+ .map(([sourcePath, origin]) => describeReleaseFile(options.appRoot, releasePrefix, sourcePath, origin))
212
+ .sort((a, b) => a.objectKey.localeCompare(b.objectKey));
213
+ return {
214
+ schemaVersion: 1,
215
+ appName: identity.appName,
216
+ appVersion: identity.appVersion,
217
+ releaseId: identity.releaseId,
218
+ target: options.target,
219
+ publishable,
220
+ builtAt: options.builtAt || new Date().toISOString(),
221
+ environmentNames: [...options.environmentNames],
222
+ destination: {
223
+ accessURL: options.accessURL || '',
224
+ releasePrefix,
225
+ baseURL: (_a = options.baseURL) !== null && _a !== void 0 ? _a : (options.accessURL
226
+ ? releaseBaseURL(options.accessURL, releasePrefix)
227
+ : ''),
228
+ },
229
+ files,
230
+ };
231
+ };
232
+ export const serializeReleaseManifest = (manifest) => `${JSON.stringify(manifest, null, 2)}\n`;
233
+ export const writeReleaseManifest = (appRoot, manifest) => {
234
+ const outputPath = path.join(appRoot, MANIFEST_SOURCE_PATH);
235
+ fs.writeFileSync(outputPath, serializeReleaseManifest(manifest));
236
+ return outputPath;
237
+ };
238
+ //# sourceMappingURL=create-release-manifest.js.map