@sempods/apps 0.0.0-stage → 0.7.0-dev

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 (69) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +19 -0
  3. package/README.md +50 -2
  4. package/bin/sempods-apps.mjs +36 -0
  5. package/instructions/app-workflow.md +210 -0
  6. package/instructions/publish.md +148 -0
  7. package/instructions/setup.md +112 -0
  8. package/package.json +59 -3
  9. package/scripts/build-site.mjs +181 -0
  10. package/scripts/check.mjs +260 -0
  11. package/scripts/configure-site.mjs +118 -0
  12. package/scripts/create.mjs +83 -0
  13. package/scripts/dev.mjs +19 -0
  14. package/scripts/lib/apps.mjs +164 -0
  15. package/scripts/lib/files.mjs +57 -0
  16. package/scripts/lib/generate.mjs +175 -0
  17. package/scripts/lib/html.mjs +3 -0
  18. package/scripts/lib/json.mjs +42 -0
  19. package/scripts/lib/merge.mjs +159 -0
  20. package/scripts/lib/overview.mjs +105 -0
  21. package/scripts/lib/paths.mjs +33 -0
  22. package/scripts/lib/pnpm.mjs +30 -0
  23. package/scripts/lib/sdk.mjs +45 -0
  24. package/scripts/lib/shared.mjs +221 -0
  25. package/scripts/migrate.mjs +550 -0
  26. package/scripts/new-app.mjs +166 -0
  27. package/scripts/regenerate.mjs +41 -0
  28. package/scripts/sdk-update.mjs +220 -0
  29. package/scripts/update.mjs +152 -0
  30. package/shared/files/0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0 +11 -0
  31. package/shared/files/1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 +61 -0
  32. package/shared/files/31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 +25 -0
  33. package/shared/files/393b09ecd18889d9e1fdf2a6df12d1f6c193fc7fcd89ba808ad94df521bad8db +41 -0
  34. package/shared/files/4660ca1887261ae658c7f587190e2b71ac91d15b1b6c4cf04dec823fdd0da654 +22 -0
  35. package/shared/files/528d7544990228d6d66fa62423775338fe1716326fc68371fb2665aee86e39b0 +6 -0
  36. package/shared/files/676e6bf33b44044fb4c62c9fe5441729bd47f2d5077ff904e57a4a1b658859ac +38 -0
  37. package/shared/files/8b7567319e18694aeca8722107281a760e3100239c6d0f548acb71e1806fa172 +28 -0
  38. package/shared/files/9945f9bd573e5af8aad2e65cc8b4284fbb1d7e8b96c4efdd42eeb49132fb4327 +4 -0
  39. package/shared/files/b911698dc3cf3227d34d0ad6727f1c4408791203c9665de99ab959331a1fb07d +1 -0
  40. package/shared/files/c2a4b026a87f6c8e7473e7fc456c6020b63c7e675087f6b58013ed5163c2f6ee +23 -0
  41. package/shared/files/d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee +11 -0
  42. package/shared/files/e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e +78 -0
  43. package/shared/files/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +0 -0
  44. package/shared/files/f04e547b89bb933c39de2f781978b93ea8b586ac9dee50312612e2f5c40391b1 +5 -0
  45. package/shared/files/f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 +57 -0
  46. package/shared/files/ff9f774d9732fdc7d704ef37ddc40ef507a1bab7ee3c1db5b1a6a8f26e6e4b37 +35 -0
  47. package/shared/snapshot.json +25 -0
  48. package/skeleton/app/.oxlintrc.json +14 -0
  49. package/skeleton/app/NOTES.md +19 -0
  50. package/skeleton/app/index.html +16 -0
  51. package/skeleton/app/package.json +28 -0
  52. package/skeleton/app/public/favicon.svg +1 -0
  53. package/skeleton/app/public/icon-192.png +0 -0
  54. package/skeleton/app/public/icon-512.png +0 -0
  55. package/skeleton/app/src/App.tsx +73 -0
  56. package/skeleton/app/src/NewVersionNotice.tsx +63 -0
  57. package/skeleton/app/src/index.css +37 -0
  58. package/skeleton/app/src/main.tsx +18 -0
  59. package/skeleton/app/src/pwa.ts +18 -0
  60. package/skeleton/app/src/start.test.ts +15 -0
  61. package/skeleton/app/src/start.ts +9 -0
  62. package/skeleton/app/tsconfig.app.json +27 -0
  63. package/skeleton/app/tsconfig.json +8 -0
  64. package/skeleton/app/tsconfig.node.json +23 -0
  65. package/skeleton/app/tsconfig.test.json +10 -0
  66. package/skeleton/app/vite.config.ts +13 -0
  67. package/skills/app-workflow/SKILL.md +22 -0
  68. package/skills/update/SKILL.md +69 -0
  69. package/update-policy.json +35 -0
@@ -0,0 +1,221 @@
1
+ // Snapshots of the starter and the baseline that records which snapshot a
2
+ // repository last applied.
3
+ //
4
+ // A published package carries the starter files (starter/ in its source) and
5
+ // their index in shared/snapshot.json:
6
+ // { "format": 1, "revision": "<sha256>", "files": { "<path>": "<sha256>" } }
7
+ // Each file is stored as shared/files/<sha256> of its content: npm leaves some
8
+ // names out of a package (.gitignore, .npmrc), and a content name survives
9
+ // packing whatever the file's path. The revision is the hash of the index, so
10
+ // the same shared files always give the same revision, whichever package
11
+ // version ships them.
12
+ //
13
+ // A repository records the snapshot it last applied in BASELINE_FILE:
14
+ // { "format": 1, "package": "@sempods/apps", "version": "<x.y.z>",
15
+ // "revision": "<sha256>" }
16
+ // The version names a published package whose snapshot has that revision, so
17
+ // the baseline stays retrievable from the registry after a newer version is
18
+ // installed. Installing a package never changes the baseline; only applying
19
+ // its shared files does.
20
+ import { spawnSync } from 'node:child_process';
21
+ import { createHash } from 'node:crypto';
22
+ import {
23
+ cpSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ mkdtempSync,
27
+ readdirSync,
28
+ readFileSync,
29
+ rmSync,
30
+ statSync,
31
+ } from 'node:fs';
32
+ import { tmpdir } from 'node:os';
33
+ import { join, relative, sep } from 'node:path';
34
+ import { ifPresent, readJson, replaceJson, writeJson } from './json.mjs';
35
+ import { EXACT_VERSION } from './sdk.mjs';
36
+
37
+ const FORMAT = 1;
38
+ export const PACKAGE = '@sempods/apps';
39
+ export const BASELINE_FILE = '.sempods-baseline.json';
40
+ const HASH = /^[0-9a-f]{64}$/;
41
+
42
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
43
+
44
+ /** Whether a policy pattern, with `*` for one path segment, names a file. */
45
+ export function matches(pattern, path) {
46
+ const p = pattern.split('/');
47
+ const f = path.split('/');
48
+ return (
49
+ p.length === f.length && p.every((part, i) => part === '*' || part === f[i])
50
+ );
51
+ }
52
+
53
+ function walk(dir, base = dir) {
54
+ return readdirSync(dir).flatMap((name) => {
55
+ if (name === 'node_modules' || name === '.git') return [];
56
+ const path = join(dir, name);
57
+ return statSync(path).isDirectory()
58
+ ? walk(path, base)
59
+ : [relative(base, path).split(sep).join('/')];
60
+ });
61
+ }
62
+
63
+ /** Every file of a starter directory, sorted. */
64
+ export function starterFiles(dir) {
65
+ return walk(dir).sort();
66
+ }
67
+
68
+ const revisionOf = (index) => sha256(JSON.stringify(index));
69
+
70
+ /** The index of a set of shared files and its revision. */
71
+ function snapshotOf(root, files) {
72
+ const index = Object.fromEntries(
73
+ [...files]
74
+ .sort()
75
+ .map((file) => [file, sha256(readFileSync(join(root, file)))]),
76
+ );
77
+ return { format: FORMAT, revision: revisionOf(index), files: index };
78
+ }
79
+
80
+ /** Writes shared/files/<sha256> and shared/snapshot.json for a starter. */
81
+ export function writeSnapshot(root, outDir) {
82
+ const files = starterFiles(root);
83
+ const snapshot = snapshotOf(root, files);
84
+ rmSync(outDir, { recursive: true, force: true });
85
+ mkdirSync(join(outDir, 'files'), { recursive: true });
86
+ for (const file of files)
87
+ cpSync(join(root, file), join(outDir, 'files', snapshot.files[file]));
88
+ writeJson(join(outDir, 'snapshot.json'), snapshot);
89
+ return snapshot;
90
+ }
91
+
92
+ /**
93
+ * Reads a snapshot directory and verifies the index and every stored file.
94
+ * `path(file)` is where the content of a shared file such as AGENTS.md is.
95
+ */
96
+ export function readSnapshot(dir) {
97
+ const snapshot = readJson(join(dir, 'snapshot.json'));
98
+ if (snapshot.format !== FORMAT)
99
+ throw new Error(`${dir}: unknown snapshot format ${snapshot.format}`);
100
+ if (revisionOf(snapshot.files) !== snapshot.revision)
101
+ throw new Error(`${dir}: the index does not match its revision`);
102
+ for (const [file, hash] of Object.entries(snapshot.files)) {
103
+ const stored = join(dir, 'files', hash);
104
+ if (!existsSync(stored) || sha256(readFileSync(stored)) !== hash)
105
+ throw new Error(`${dir}: ${file} is missing or does not match its index`);
106
+ }
107
+ return {
108
+ ...snapshot,
109
+ path: (file) => {
110
+ if (!Object.hasOwn(snapshot.files, file))
111
+ throw new Error(`${file} is not a shared file of this snapshot`);
112
+ return join(dir, 'files', snapshot.files[file]);
113
+ },
114
+ };
115
+ }
116
+
117
+ /** The baseline a repository records, or undefined when it has none. */
118
+ export function readBaseline(root) {
119
+ const file = join(root, BASELINE_FILE);
120
+ if (!existsSync(file)) return undefined;
121
+ const baseline = readJson(file);
122
+ const problems = [];
123
+ if (baseline.format !== FORMAT) problems.push(`format must be ${FORMAT}`);
124
+ if (baseline.package !== PACKAGE) problems.push(`package must be ${PACKAGE}`);
125
+ if (!EXACT_VERSION.test(baseline.version ?? ''))
126
+ problems.push('version must be an exact version');
127
+ if (!HASH.test(baseline.revision ?? ''))
128
+ problems.push('revision must be a sha256 hex digest');
129
+ if (problems.length > 0)
130
+ throw new Error(`${BASELINE_FILE}: ${problems.join('; ')}`);
131
+ return baseline;
132
+ }
133
+
134
+ /** Records that a repository has applied the snapshot of a package version. */
135
+ export function writeBaseline(root, { version, revision }) {
136
+ replaceJson(join(root, BASELINE_FILE), {
137
+ format: FORMAT,
138
+ package: PACKAGE,
139
+ version,
140
+ revision,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Unpacks a published package version into `into` and returns its directory.
146
+ * SEMPODS_APPS_PACKAGES may name a directory of packed tarballs
147
+ * (sempods-apps-<version>.tgz) to use instead of the registry, for offline
148
+ * tests of versions not published yet.
149
+ */
150
+ export function downloadPackage(
151
+ version,
152
+ into = mkdtempSync(join(tmpdir(), 'sempods-apps-')),
153
+ ) {
154
+ mkdirSync(into, { recursive: true });
155
+ const run = (command, args) => {
156
+ const result = spawnSync(command, args, {
157
+ cwd: into,
158
+ encoding: 'utf8',
159
+ shell: process.platform === 'win32',
160
+ });
161
+ if (result.status !== 0)
162
+ throw new Error(
163
+ `${command} ${args.join(' ')} failed: ${result.stderr.trim()}`,
164
+ );
165
+ return result.stdout;
166
+ };
167
+ const local = process.env.SEMPODS_APPS_PACKAGES;
168
+ const tarball = local
169
+ ? join(local, `sempods-apps-${version}.tgz`)
170
+ : run('npm', ['pack', `${PACKAGE}@${version}`, '--silent'])
171
+ .trim()
172
+ .split('\n')
173
+ .at(-1);
174
+ run('tar', ['-xzf', tarball]);
175
+ return join(into, 'package');
176
+ }
177
+
178
+ /** The starter a packed package directory ships: version, snapshot, policy. */
179
+ export function starterAt(dir) {
180
+ const shared = join(dir, 'shared');
181
+ if (!existsSync(join(shared, 'snapshot.json')))
182
+ throw new Error(
183
+ `${PACKAGE} at ${dir} has no packed starter; use a published version`,
184
+ );
185
+ return {
186
+ version: readJson(join(dir, 'package.json')).version,
187
+ snapshot: readSnapshot(shared),
188
+ policy: ifPresent(() => readJson(join(dir, 'update-policy.json'))),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * The starter a baseline names, from the installed package when it is that
194
+ * version, otherwise from the published one.
195
+ */
196
+ export function baselineSnapshot(
197
+ baseline,
198
+ { installed, download = downloadPackage } = {},
199
+ ) {
200
+ const local =
201
+ installed &&
202
+ readJson(join(installed, 'package.json')).version === baseline.version;
203
+ const { snapshot, policy } = starterAt(
204
+ local ? installed : download(baseline.version),
205
+ );
206
+ if (snapshot.revision !== baseline.revision)
207
+ throw new Error(
208
+ `${PACKAGE}@${baseline.version} ships shared-file revision ${snapshot.revision}, not the recorded ${baseline.revision}`,
209
+ );
210
+ return { ...snapshot, policy };
211
+ }
212
+
213
+ /** The @sempods/apps version a repository declares, as written. */
214
+ export const declaredVersion = (root) =>
215
+ readJson(join(root, 'package.json')).devDependencies?.[PACKAGE];
216
+
217
+ /** The @sempods/apps version installed in a repository, if any. */
218
+ export const installedVersion = (root) =>
219
+ ifPresent(
220
+ () => readJson(join(root, 'node_modules', PACKAGE, 'package.json')).version,
221
+ );