@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,166 @@
1
+ #!/usr/bin/env node
2
+ // Creates apps/<id> from the skeleton with the root's SDK version and adds it
3
+ // to apps.json.
4
+ // Usage: pnpm run new-app <id> [--title "<title>"] [--language <de|en>] [--no-pwa]
5
+ import {
6
+ cpSync,
7
+ existsSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { extname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { parseArgs } from 'node:util';
16
+ import {
17
+ invalidId,
18
+ LANGUAGES,
19
+ nextDevPort,
20
+ readApps,
21
+ writeApps,
22
+ } from './lib/apps.mjs';
23
+ import { writeGenerated } from './lib/generate.mjs';
24
+ import { escapeHtml } from './lib/html.mjs';
25
+ import { readJson, writeJson } from './lib/json.mjs';
26
+ import { repositoryRoot, SKELETON } from './lib/paths.mjs';
27
+ import { INSTALL, pnpm, scriptArgs } from './lib/pnpm.mjs';
28
+ import { EXACT_VERSION, SDK } from './lib/sdk.mjs';
29
+
30
+ const TEXT = new Set(['.json', '.html', '.md', '.ts', '.tsx', '.css']);
31
+
32
+ function fillTokens(dir, app) {
33
+ // Directory entries carry their type, so no separate stat precedes the read
34
+ // and write of the same path.
35
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
36
+ const { name } = entry;
37
+ const path = join(dir, name);
38
+ if (entry.isDirectory()) {
39
+ fillTokens(path, app);
40
+ continue;
41
+ }
42
+ if (!TEXT.has(extname(name))) continue;
43
+ const title = extname(name) === '.html' ? escapeHtml(app.title) : app.title;
44
+ const source = readFileSync(path, 'utf8');
45
+ const filled = source
46
+ .replaceAll('__APP_ID__', app.id)
47
+ .replaceAll('__APP_TITLE__', title)
48
+ .replaceAll('__APP_LANGUAGE__', app.language);
49
+ if (filled !== source) writeFileSync(path, filled);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The SDK versions a new app declares: the root's, so all apps share one
55
+ * version. Each app still declares them itself and stays exportable.
56
+ */
57
+ function rootSdk(root) {
58
+ const { devDependencies = {} } = readJson(join(root, 'package.json'));
59
+ return Object.fromEntries(
60
+ SDK.map((name) => {
61
+ const version = devDependencies[name];
62
+ if (!EXACT_VERSION.test(version ?? ''))
63
+ throw new Error(
64
+ `package.json must declare ${name} with an exact version in devDependencies`,
65
+ );
66
+ return [name, version];
67
+ }),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Creates one app. Throws with a one-line reason and changes nothing when the
73
+ * ID is invalid or already used.
74
+ */
75
+ export function createApp(
76
+ root,
77
+ { id, title = id, language = 'en', pwa = true },
78
+ ) {
79
+ const reason = invalidId(id);
80
+ if (reason) throw new Error(reason);
81
+ if (!LANGUAGES.includes(language))
82
+ throw new Error(`language must be one of ${LANGUAGES.join(', ')}`);
83
+ if (typeof title !== 'string' || title.trim() === '')
84
+ throw new Error('the title must not be empty');
85
+ const manifest = readApps(root);
86
+ const appDir = join(root, 'apps', id);
87
+ if (manifest.apps.some((app) => app.id === id) || existsSync(appDir))
88
+ throw new Error(`apps/${id} already exists; nothing was changed`);
89
+
90
+ const sdk = rootSdk(root);
91
+ const app = {
92
+ id,
93
+ title: title.trim(),
94
+ language,
95
+ path: `/${id}/`,
96
+ devPort: nextDevPort(manifest),
97
+ pwa,
98
+ };
99
+ try {
100
+ cpSync(SKELETON, appDir, { recursive: true, errorOnExist: true });
101
+ fillTokens(appDir, app);
102
+ const pkgFile = join(appDir, 'package.json');
103
+ const pkg = readJson(pkgFile);
104
+ // Package managers keep dependency lists sorted.
105
+ pkg.dependencies = Object.fromEntries(
106
+ Object.entries({ ...pkg.dependencies, ...sdk }).sort(([a], [b]) =>
107
+ a < b ? -1 : a > b ? 1 : 0,
108
+ ),
109
+ );
110
+ writeJson(pkgFile, pkg);
111
+ writeGenerated(appDir, app, manifest.site);
112
+ writeApps(root, { ...manifest, apps: [...manifest.apps, app] });
113
+ } catch (error) {
114
+ rmSync(appDir, { recursive: true, force: true });
115
+ throw error;
116
+ }
117
+ return app;
118
+ }
119
+
120
+ function main() {
121
+ const { values, positionals } = parseArgs({
122
+ args: scriptArgs(),
123
+ allowPositionals: true,
124
+ options: {
125
+ title: { type: 'string' },
126
+ language: { type: 'string' },
127
+ 'no-pwa': { type: 'boolean' },
128
+ 'skip-install': { type: 'boolean' },
129
+ },
130
+ });
131
+ if (positionals.length !== 1) {
132
+ console.error(
133
+ 'Usage: pnpm run new-app <id> [--title "<title>"] [--language <de|en>] [--no-pwa]',
134
+ );
135
+ process.exit(2);
136
+ }
137
+ const root = repositoryRoot();
138
+ let app;
139
+ try {
140
+ app = createApp(root, {
141
+ id: positionals[0],
142
+ title: values.title,
143
+ language: values.language,
144
+ pwa: !values['no-pwa'],
145
+ });
146
+ } catch (error) {
147
+ console.error(`new-app: ${error.message}`);
148
+ process.exit(1);
149
+ }
150
+ console.log(`Created apps/${app.id} (${app.title}).`);
151
+ if (!values['skip-install']) {
152
+ const install = pnpm(INSTALL, { cwd: root });
153
+ if (install.status !== 0) {
154
+ console.error('pnpm install failed; run it again, then start the app.');
155
+ process.exit(1);
156
+ }
157
+ }
158
+ console.log(`Start it: pnpm run dev ${app.id}`);
159
+ console.log(`Then open http://127.0.0.1:${app.devPort}${app.path}`);
160
+ }
161
+
162
+ if (
163
+ process.argv[1] &&
164
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
165
+ )
166
+ main();
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // Rewrites every app's generated configuration from apps.json, for example
3
+ // after a tooling update changed the generator. Usage: pnpm run regenerate
4
+ import { existsSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { readApps } from './lib/apps.mjs';
8
+ import { staleGenerated, writeGenerated } from './lib/generate.mjs';
9
+ import { repositoryRoot } from './lib/paths.mjs';
10
+
11
+ /** Rewrites the generated files that differ; returns them as apps/<id>/<file>. */
12
+ export function regenerate(root) {
13
+ const { apps, site } = readApps(root);
14
+ const changed = [];
15
+ for (const app of apps) {
16
+ const dir = join(root, 'apps', app.id);
17
+ if (!existsSync(dir)) continue;
18
+ const stale = staleGenerated(dir, app, site);
19
+ if (stale.length === 0) continue;
20
+ writeGenerated(dir, app, site);
21
+ changed.push(...stale.map((file) => `apps/${app.id}/${file}`));
22
+ }
23
+ return changed;
24
+ }
25
+
26
+ if (
27
+ process.argv[1] &&
28
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
29
+ ) {
30
+ try {
31
+ const changed = regenerate(repositoryRoot());
32
+ console.log(
33
+ changed.length
34
+ ? `Regenerated:\n${changed.map((file) => ` ${file}`).join('\n')}`
35
+ : 'Generated configuration is up to date.',
36
+ );
37
+ } catch (error) {
38
+ console.error(`regenerate: ${error.message}`);
39
+ process.exitCode = 1;
40
+ }
41
+ }
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ // One SDK release for the root and every registered app: the manifests name
3
+ // it, pnpm installs it, and the update counts only once node_modules has it
4
+ // and check passes. Usage: pnpm run sdk-update <version|latest>
5
+ import {
6
+ existsSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { parseArgs } from 'node:util';
14
+ import { readApps } from './lib/apps.mjs';
15
+ import { formatJson, ifPresent, readJson } from './lib/json.mjs';
16
+ import { migrationState } from './migrate.mjs';
17
+ import { repositoryRoot } from './lib/paths.mjs';
18
+ import { INSTALL, pnpm, pnpmJson, scriptArgs } from './lib/pnpm.mjs';
19
+ import {
20
+ compareVersions,
21
+ EXACT_VERSION,
22
+ PENDING_UPDATE,
23
+ REFERENCE,
24
+ SDK,
25
+ } from './lib/sdk.mjs';
26
+
27
+
28
+ function pnpmOrThrow(root, args) {
29
+ if (pnpm(args, { cwd: root }).status !== 0)
30
+ throw new Error(
31
+ `pnpm ${args.join(' ')} failed; fix the reported problem and rerun`,
32
+ );
33
+ }
34
+
35
+ export function registryLookup(root, name, version) {
36
+ try {
37
+ return pnpmJson(root, [
38
+ 'view',
39
+ `${name}@${version}`,
40
+ 'version',
41
+ 'dependencies',
42
+ '--json',
43
+ ]);
44
+ } catch (error) {
45
+ throw new Error(
46
+ `Cannot resolve ${name}@${version} from the npm registry (missing version or registry unavailable): ${error.message}`,
47
+ );
48
+ }
49
+ }
50
+
51
+ export function releaseLinks(version) {
52
+ return {
53
+ release: `https://github.com/sempods/sempods-typescript/releases/tag/v${version}`,
54
+ migration: `https://github.com/sempods/sempods-typescript/blob/v${version}/docs/migration.md`,
55
+ };
56
+ }
57
+
58
+ /** Plan everything before writing. Registry injection keeps tests offline. */
59
+ export function planUpdate(
60
+ root,
61
+ requested = 'latest',
62
+ {
63
+ allowDowngrade = false,
64
+ lookup = (name, version) => registryLookup(root, name, version),
65
+ } = {},
66
+ ) {
67
+ if (requested !== 'latest' && !EXACT_VERSION.test(requested))
68
+ throw new Error(
69
+ 'Use latest or an exact semver (for example 0.3.0), not a range or another dist-tag',
70
+ );
71
+ const app = lookup(SDK[0], requested);
72
+ const version = app?.version;
73
+ if (
74
+ !EXACT_VERSION.test(version ?? '') ||
75
+ (requested !== 'latest' && version !== requested)
76
+ )
77
+ throw new Error(
78
+ `${SDK[0]}@${requested} is missing or returned an unexpected version`,
79
+ );
80
+ const client = lookup(SDK[1], version);
81
+ if (client?.version !== version)
82
+ throw new Error(
83
+ `${SDK[1]}@${version} is missing or returned an unexpected version`,
84
+ );
85
+ if (app.dependencies?.[SDK[1]] !== version)
86
+ throw new Error(
87
+ `${SDK[0]}@${version} must depend on exactly ${SDK[1]}@${version}; found ${JSON.stringify(app.dependencies?.[SDK[1]])}`,
88
+ );
89
+
90
+ const entries = [
91
+ ['package.json', 'devDependencies'],
92
+ ...readApps(root).apps.map(({ id }) => [
93
+ `apps/${id}/package.json`,
94
+ 'dependencies',
95
+ ]),
96
+ ];
97
+ const changes = [];
98
+ for (const [file, section] of entries) {
99
+ const path = join(root, file);
100
+ const original = readFileSync(path, 'utf8');
101
+ const manifest = JSON.parse(original);
102
+ for (const name of SDK) {
103
+ const current = manifest[section]?.[name];
104
+ if (!EXACT_VERSION.test(current ?? ''))
105
+ throw new Error(
106
+ `${file}: ${section}.${name} must already be an exact semver`,
107
+ );
108
+ if (!allowDowngrade && compareVersions(version, current) < 0)
109
+ throw new Error(
110
+ `${file}: downgrade from ${current} to ${version} requires --allow-downgrade`,
111
+ );
112
+ }
113
+ if (SDK.every((name) => manifest[section][name] === version)) continue;
114
+ for (const name of SDK) manifest[section][name] = version;
115
+ changes.push({
116
+ path,
117
+ original,
118
+ updated: formatJson(manifest),
119
+ });
120
+ }
121
+ return { version, changes, ...releaseLinks(version) };
122
+ }
123
+
124
+
125
+ /** Whether node_modules has both SDK packages at `version` and the reference. */
126
+ export function installedStateComplete(root, version) {
127
+ try {
128
+ return (
129
+ SDK.every(
130
+ (name) =>
131
+ readJson(join(root, 'node_modules', name, 'package.json'))
132
+ .version === version,
133
+ ) &&
134
+ existsSync(join(root, REFERENCE))
135
+ );
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /** On a failed install/check leave a reviewable diff and report the failure. */
142
+ export function applyUpdate(
143
+ root,
144
+ plan,
145
+ {
146
+ run = (args) => pnpmOrThrow(root, args),
147
+ installed = () => installedStateComplete(root, plan.version),
148
+ // pnpm confirms that the lockfile matches every manifest.
149
+ locked = () =>
150
+ pnpm(['install', '--frozen-lockfile'], { cwd: root }).status === 0,
151
+ migration = () => migrationState(root),
152
+ } = {},
153
+ ) {
154
+ const pending = join(root, PENDING_UPDATE);
155
+ // Read rather than tested, so nothing races between check and write.
156
+ const resuming =
157
+ ifPresent(() => readFileSync(pending, 'utf8')) !== undefined;
158
+ if (
159
+ plan.changes.length === 0 &&
160
+ !resuming &&
161
+ installed() &&
162
+ locked()
163
+ )
164
+ return false;
165
+ // Its check would fail on the migration, which waits for this update.
166
+ const open = !resuming && migration();
167
+ if (open)
168
+ throw new Error(
169
+ `finish the tooling migration first: ${open}. Nothing was changed.`,
170
+ );
171
+ writeFileSync(pending, `${plan.version}\n`);
172
+ for (const { path, updated } of plan.changes) writeFileSync(path, updated);
173
+ run(INSTALL);
174
+ if (!installed())
175
+ throw new Error(
176
+ `node_modules does not have both SDK packages at ${plan.version} with ${REFERENCE}; fix the cause and run the same command again`,
177
+ );
178
+ run(['run', 'check']);
179
+ rmSync(pending);
180
+ return true;
181
+ }
182
+
183
+ function main() {
184
+ const { values, positionals } = parseArgs({
185
+ args: scriptArgs(),
186
+ allowPositionals: true,
187
+ options: {
188
+ 'allow-downgrade': { type: 'boolean' },
189
+ },
190
+ });
191
+ if (positionals.length !== 1)
192
+ throw new Error(
193
+ 'Usage: pnpm run sdk-update <version|latest> [--allow-downgrade]',
194
+ );
195
+ const root = repositoryRoot();
196
+ const plan = planUpdate(root, positionals[0], {
197
+ allowDowngrade: values['allow-downgrade'],
198
+ });
199
+ const changed = applyUpdate(root, plan);
200
+ console.log(
201
+ changed
202
+ ? `Updated SDK to ${plan.version}.`
203
+ : `Already at SDK ${plan.version}; nothing changed.`,
204
+ );
205
+ console.log(
206
+ `SDK release: ${plan.release}\nMigration guide: ${plan.migration}`,
207
+ );
208
+ }
209
+
210
+ if (
211
+ process.argv[1] &&
212
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
213
+ ) {
214
+ try {
215
+ main();
216
+ } catch (error) {
217
+ console.error(`sdk-update: ${error.message}`);
218
+ process.exitCode = 1;
219
+ }
220
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // Updates the tooling: checks that this machine's Node and pnpm suit the
3
+ // target @sempods/apps, installs it and then runs its own migrate, so the new
4
+ // version applies its starter. Nothing changes when a prerequisite is
5
+ // missing. Usage: pnpm run update [<version>|latest]
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import semver from 'semver';
11
+ import { refuseLinks, refuseUnsafe } from './migrate.mjs';
12
+ import { repositoryRoot, TOOLING } from './lib/paths.mjs';
13
+ import { pnpm, pnpmJson, scriptArgs } from './lib/pnpm.mjs';
14
+ import { EXACT_VERSION } from './lib/sdk.mjs';
15
+ import {
16
+ baselineSnapshot,
17
+ downloadPackage,
18
+ installedVersion,
19
+ PACKAGE,
20
+ readBaseline,
21
+ starterAt,
22
+ } from './lib/shared.mjs';
23
+
24
+ /** Problems that stop the update before anything is installed. */
25
+ export function preflight(target, { node, pnpmVersion }) {
26
+ const problems = [];
27
+ const { node: needsNode, pnpm: needsPnpm } = target.engines ?? {};
28
+ if (needsNode && !semver.satisfies(node, needsNode))
29
+ problems.push({ tool: 'Node', current: node, range: needsNode });
30
+ if (needsPnpm && pnpmVersion && !semver.satisfies(pnpmVersion, needsPnpm))
31
+ problems.push({ tool: 'pnpm', current: pnpmVersion, range: needsPnpm });
32
+ return problems;
33
+ }
34
+
35
+ /** The order to follow when the target needs newer tools. */
36
+ export function prerequisiteOrder(target, problems) {
37
+ const steps = [
38
+ ...problems.map(({ tool, range }) =>
39
+ tool === 'Node'
40
+ ? `Install a Node version in ${range} and write it to .node-version.`
41
+ : `Set packageManager in package.json to a pnpm version in ${range}; pnpm switches to it by itself.`,
42
+ ),
43
+ `Run pnpm run update ${target.version} again.`,
44
+ ];
45
+ return [
46
+ `${PACKAGE} ${target.version} needs ${problems.map(({ tool, current, range }) => `${tool} ${range} (this machine has ${current})`).join(' and ')}. Nothing was changed.`,
47
+ ...steps.map((step, i) => `${i + 1}. ${step}`),
48
+ ].join('\n');
49
+ }
50
+
51
+ /**
52
+ * Refuses before installing when a path the old or the new starter writes is
53
+ * a symbolic link here: the installed version's migrate would refuse only
54
+ * after the install.
55
+ */
56
+ export function refuseLinkedStarter(
57
+ root,
58
+ version,
59
+ { download = downloadPackage, installed = TOOLING } = {},
60
+ ) {
61
+ const dir = mkdtempSync(join(tmpdir(), 'sempods-update-'));
62
+ try {
63
+ const files = Object.keys(
64
+ starterAt(download(version, join(dir, 'target'))).snapshot.files,
65
+ );
66
+ const baseline = readBaseline(root);
67
+ if (baseline)
68
+ files.push(
69
+ ...Object.keys(
70
+ baselineSnapshot(baseline, {
71
+ installed,
72
+ download: (from) => download(from, join(dir, 'base')),
73
+ }).files,
74
+ ),
75
+ );
76
+ refuseLinks(root, files);
77
+ } finally {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Declares and installs exactly `version`, then checks that node_modules has
84
+ * it: a version pull request may name it before anything is installed.
85
+ */
86
+ export function installTarget(
87
+ root,
88
+ version,
89
+ {
90
+ run = (args) => pnpm(args, { cwd: root }).status === 0,
91
+ installed = () => installedVersion(root),
92
+ } = {},
93
+ ) {
94
+ // The repository is a pnpm workspace; the tooling belongs to its root.
95
+ const add = ['add', '-w', '--save-dev', '--save-exact'];
96
+ if (!run([...add, `${PACKAGE}@${version}`]))
97
+ throw new Error(
98
+ `installing ${PACKAGE} ${version} failed; fix the cause and run the update again`,
99
+ );
100
+ const now = installed();
101
+ if (now !== version)
102
+ throw new Error(
103
+ `${PACKAGE} ${now ?? '(none)'} is installed instead of ${version}; run pnpm install and the update again`,
104
+ );
105
+ }
106
+
107
+ if (
108
+ process.argv[1] &&
109
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
110
+ ) {
111
+ try {
112
+ const args = scriptArgs();
113
+ if (args.length > 1)
114
+ throw new Error('Usage: pnpm run update [<version>|latest]');
115
+ const spec = args[0] ?? 'latest';
116
+ if (spec !== 'latest' && !EXACT_VERSION.test(spec))
117
+ throw new Error('Use latest or an exact version, for example 0.7.0');
118
+ const root = repositoryRoot();
119
+ const found = pnpmJson(root, [
120
+ 'view',
121
+ `${PACKAGE}@${spec}`,
122
+ 'version',
123
+ 'engines',
124
+ '--json',
125
+ ]);
126
+ // With only one of the fields present, pnpm prints that value alone.
127
+ const target = typeof found === 'string' ? { version: found } : found;
128
+ refuseUnsafe(root, target.version);
129
+ const version = pnpm(['--version'], {
130
+ cwd: root,
131
+ stdio: 'pipe',
132
+ encoding: 'utf8',
133
+ });
134
+ const problems = preflight(target, {
135
+ node: process.versions.node,
136
+ pnpmVersion: version.status === 0 ? version.stdout.trim() : undefined,
137
+ });
138
+ if (problems.length > 0) {
139
+ console.error(prerequisiteOrder(target, problems));
140
+ process.exitCode = 2;
141
+ } else {
142
+ refuseLinkedStarter(root, target.version);
143
+ installTarget(root, target.version);
144
+ // The installed version migrates with its own code.
145
+ process.exitCode =
146
+ pnpm(['exec', 'sempods-apps', 'migrate'], { cwd: root }).status ?? 1;
147
+ }
148
+ } catch (error) {
149
+ console.error(`update: ${error.message}`);
150
+ process.exitCode = 1;
151
+ }
152
+ }
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: app-workflow
3
+ description:
4
+ Create or change an app in a sempods apps repository using its canonical app
5
+ workflow. Not for tooling or SDK maintenance.
6
+ ---
7
+
8
+ Read the canonical app-workflow skill that the installed tooling package ships,
9
+ `node_modules/@sempods/apps/skills/app-workflow/SKILL.md`. If `node_modules` is
10
+ missing, follow the bootstrap steps in `INIT.md` first. The skill routes to
11
+ AGENTS.md, setup and the shared instructions.
@@ -0,0 +1,61 @@
1
+ # Make an app with your assistant
2
+
3
+ ## Start
4
+
5
+ You need Node.js in the version `.node-version` names. With a version manager,
6
+ name that version explicitly, for example with `nvm install` or
7
+ `fnm use --install-if-missing` followed by the version. The installation stops
8
+ with a clear message on an older Node.
9
+
10
+ You also need pnpm. With Node 24, run `corepack enable pnpm`; otherwise install
11
+ pnpm with npm. `package.json` names the exact pnpm version, and pnpm switches
12
+ to it by itself.
13
+
14
+ [Open this repository in your assistant and paste the starter prompt](../README.md#start-with-your-idea).
15
+ Describe one thing you want to do: “Add something to my shopping list.” Your
16
+ assistant sets up the app and gives you a local link to open.
17
+ It chooses the technical details and asks you about what the app should do.
18
+ Review rules and automatic updates can wait until you have tried the first
19
+ version.
20
+
21
+ ## Change
22
+
23
+ Ask for the next small improvement: “Let me mark an item as bought.” Keep trying
24
+ the app as it grows. For another app, say “Create a new app for my book list.”
25
+ Your existing apps stay in the same repository.
26
+
27
+ ## Try
28
+
29
+ Use a test context with made-up data in your Pod. Sign in on the Pod's page,
30
+ then choose that context in the app. Ask your assistant to check a save and a
31
+ reload, and to tell you what it has not tested. Never paste passwords or tokens
32
+ into chat. Without a Pod, you can still try the screen.
33
+
34
+ ## Keep apps up to date
35
+
36
+ After trying your first app, you can ask your assistant to turn on update
37
+ suggestions: GitHub then proposes each new SDK or tooling version as a change
38
+ for you to review. Until then, it proposes none; security fixes still arrive.
39
+ Ask your assistant: “Update my SDK and explain what I should try.” A patch
40
+ update is merged when checks pass. Before 1.0 a minor update may break apps;
41
+ your assistant follows the migration guide, adapts your apps and lists what to
42
+ try on the Pod. You decide when to merge.
43
+
44
+ When the tooling gets better, ask your assistant: “Update the tooling.” It
45
+ brings in the new version as a change you can review and tells you if your
46
+ computer needs a newer Node or pnpm first. Your apps, notes and instructions
47
+ stay yours. Where the update and one of your changes overlap, your assistant
48
+ combines them and asks you when it is a matter of choice.
49
+
50
+ ## Publish
51
+
52
+ When an app is ready, ask your assistant: “Publish my apps.” It asks for the
53
+ web address you own, for example a free Netlify or Cloudflare address, and
54
+ optionally a second address for previews. All your apps then share one site,
55
+ each under its own path, with an overview page. Choose the address once:
56
+ changing it later means signing in to every app again. On the published site,
57
+ apps can be installed on a device. Offline startup will not make Pod edits
58
+ work offline. You decide when to publish.
59
+
60
+ Your assistant follows the repository's instructions and records progress in
61
+ each app's `NOTES.md`, so a later session can pick up where you left off.
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "my-sempods-apps",
3
+ "private": true,
4
+ "type": "module",
5
+ "packageManager": "pnpm@11.28.2",
6
+ "engines": {
7
+ "node": ">=24.15.0"
8
+ },
9
+ "scripts": {
10
+ "new-app": "sempods-apps new-app",
11
+ "dev": "sempods-apps dev",
12
+ "regenerate": "sempods-apps regenerate",
13
+ "configure-site": "sempods-apps configure-site",
14
+ "build-site": "sempods-apps build-site",
15
+ "check": "sempods-apps check",
16
+ "sdk-update": "sempods-apps sdk-update",
17
+ "update": "sempods-apps update",
18
+ "migrate": "sempods-apps migrate"
19
+ },
20
+ "devDependencies": {
21
+ "@sempods/app-sdk": "0.5.0",
22
+ "@sempods/apps": "0.0.0",
23
+ "@sempods/client-sdk": "0.5.0"
24
+ }
25
+ }