create-satin 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Satin contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # create-satin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Create a Vite + React app for the iPhone Home Screen runtime.
4
+
5
+ After the beta is published:
6
+
7
+ ```sh
8
+ npm create satin@beta -- my-app --yes --name "My App" --id my-app
9
+ # or
10
+ pnpm create satin@beta my-app --yes --name "My App" --id my-app
11
+ ```
12
+
13
+ Run `create-satin --help` for all flags, including launch colours and a local
14
+ Satin tarball override. The command copies a bundled template, refuses to
15
+ overwrite a nonempty directory, and leaves installation to you. It has no
16
+ runtime dependencies. Node 22.12+ on the 22.x line, or Node 24+, is required.
17
+
18
+ Quote destination paths containing spaces. The folder keeps its requested name;
19
+ the npm package name and default app ID use a lowercase name with other characters
20
+ replaced by hyphens and edge hyphens removed. An empty result uses `my-app`.
21
+ For example, `"My App #%?"` creates that folder with package name `my-app` and ID `my-app`.
22
+
23
+ `--icon` copies one square PNG source; the build derives the install icon sizes.
24
+ `--accent` writes `appearance.tint`; Satin applies the tint at runtime. Without it no tint is written and the app uses the system blue.
25
+ Launch colours write the light/dark `appearance` backgrounds. If a bundled
26
+ template cannot apply a flag, the command fails before creating the destination.
27
+
28
+ The template has Home and Settings tabs with independent navigation stacks, a
29
+ saved-item detail route and a Your data route that exports JSON. It uses useQuery
30
+ and useRecord for local data. It does not import data. Add validation, a preview,
31
+ backup and confirmation before building an import flow.
32
+
33
+ The app includes README.md, AGENTS.md, IOS-DESIGN.md and GETTING-STARTED.md.
34
+ The packaged API reference documents each export and its limitations.
35
+
36
+ Maintainers edit `templates/basic/` in the repository. `npm run build` copies it
37
+ into this package before packing, so the published CLI works outside the repo.
38
+
39
+ Set `SATIN_DOCS_BASE_URL` when building or packing the CLI to choose where the
40
+ generated `GETTING-STARTED.md` links resolve. It is an HTTP(S) repository-root
41
+ URL containing both `docs/` and `packages/`; include the release revision for
42
+ versioned source documentation. For example:
43
+
44
+ ```sh
45
+ SATIN_DOCS_BASE_URL=https://docs.example.com/satin/v0.1.0-beta.0/ npm run build
46
+ ```
47
+
48
+ The default uses the configured source repository at its default revision. Before publication,
49
+ set the docs base URL to the chosen public repository and release revision.
50
+ The build normalizes the trailing slash and rejects credentials, query strings,
51
+ fragments and non-HTTP schemes. External service links and page anchors keep
52
+ their original targets. This is a packaging option; app creators do not need to
53
+ set it. Packaging checks every shipped template Markdown file, the generated
54
+ guide and both package READMEs for private project-status language.
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ import { cp, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
3
+ import { basename, resolve } from 'node:path'
4
+ import { createInterface } from 'node:readline/promises'
5
+ import { parseArgs } from 'node:util'
6
+
7
+ const help = `Create a Satin app
8
+
9
+ npm create satin@beta -- my-app --yes
10
+ pnpm create satin@beta my-app --yes
11
+
12
+ Options:
13
+ --name "My App" Display name, default My Satin App
14
+ --id my-app Stable app identity, default <directory name>
15
+ --background '#f2f2f7' Light launch and app background
16
+ --dark-background '#000000' Dark launch and app background
17
+ --icon <file.png> App icon, copied into the generated project
18
+ --accent '#007aff' App tint colour (appearance.tint); omit for the system blue
19
+ --satin-package <spec> Override dependency, e.g. /absolute/satin.tgz
20
+ --yes Use defaults for omitted answers
21
+ --help Show this help
22
+
23
+ The destination must be absent or empty. No packages are installed automatically.
24
+ `
25
+ const hex = /^#[\da-f]{6}$/iu
26
+ const jsString = value => JSON.stringify(value).replaceAll('<', '\\u003c')
27
+
28
+ function packageNameForDirectory(directory) {
29
+ return (
30
+ basename(resolve(directory))
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9-]+/gu, '-')
33
+ .replace(/^-+|-+$/gu, '') || 'my-app'
34
+ )
35
+ }
36
+
37
+ function appIdForDirectory(directory) {
38
+ const name = packageNameForDirectory(directory)
39
+ return /^[a-z]/u.test(name) ? name : `app-${name}`
40
+ }
41
+
42
+ function replaceRequired(source, search, replacement, flag, expectedMatches = 1) {
43
+ const matches =
44
+ typeof search === 'string' ? source.split(search).length - 1 : source.match(search) ? 1 : 0
45
+ if (matches !== expectedMatches) {
46
+ throw new Error(
47
+ `Cannot apply --${flag}: bundled src/app.ts expected ${expectedMatches} match(es) for ${search}, found ${matches}.`,
48
+ )
49
+ }
50
+ return typeof search === 'string'
51
+ ? source.replaceAll(search, replacement)
52
+ : source.replace(search, replacement)
53
+ }
54
+
55
+ async function main() {
56
+ const { values, positionals } = parseArgs({
57
+ allowPositionals: true,
58
+ options: {
59
+ name: { type: 'string' },
60
+ id: { type: 'string' },
61
+ background: { type: 'string', default: '#f2f2f7' },
62
+ 'dark-background': { type: 'string', default: '#000000' },
63
+ icon: { type: 'string' },
64
+ accent: { type: 'string' },
65
+ 'satin-package': { type: 'string' },
66
+ yes: { type: 'boolean', short: 'y' },
67
+ help: { type: 'boolean', short: 'h' },
68
+ },
69
+ })
70
+ if (values.help) {
71
+ console.log(help)
72
+ return
73
+ }
74
+ if (positionals.length > 1) throw new Error('Provide one destination directory.')
75
+ let destination = positionals[0]
76
+ let name = values.name
77
+ let id = values.id
78
+ if (!values.yes && process.stdin.isTTY && process.stdout.isTTY) {
79
+ const prompt = createInterface({ input: process.stdin, output: process.stdout })
80
+ try {
81
+ destination ??= (await prompt.question('Directory [my-app]: ')) || 'my-app'
82
+ name ??= (await prompt.question('App name [My Satin App]: ')) || 'My Satin App'
83
+ id ??= (await prompt.question(`App ID [${appIdForDirectory(destination)}]: `)) || undefined
84
+ } finally {
85
+ prompt.close()
86
+ }
87
+ }
88
+ destination ??= 'my-app'
89
+ const target = resolve(destination)
90
+ const packageName = packageNameForDirectory(target)
91
+ name ??= 'My Satin App'
92
+ id ??= appIdForDirectory(destination)
93
+ if (packageName.length > 214) {
94
+ throw new Error('Package name derived from the directory must be at most 214 characters.')
95
+ }
96
+ if (
97
+ !name.trim() ||
98
+ name !== name.trim() ||
99
+ name.length > 80 ||
100
+ [...name].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
101
+ ) {
102
+ throw new Error('App name must be 1–80 characters, without padding or control characters.')
103
+ }
104
+ if (!/^[a-z][a-z0-9-]*$/u.test(id))
105
+ throw new Error('App ID must be a lowercase slug such as my-app.')
106
+ for (const key of ['background', 'dark-background', 'accent']) {
107
+ // Without --accent the app keeps the system blue, which differs between light and dark.
108
+ if (key === 'accent' && values.accent === undefined) continue
109
+ if (!hex.test(values[key])) throw new Error(`--${key} must be a six-digit hex colour.`)
110
+ }
111
+ let suppliedIcon
112
+ if (values.icon) {
113
+ const bytes = await readFile(resolve(values.icon))
114
+ if (bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a')
115
+ throw new Error('--icon must be a PNG file.')
116
+ suppliedIcon = { bytes, width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }
117
+ if (!suppliedIcon.width || suppliedIcon.width !== suppliedIcon.height)
118
+ throw new Error('--icon must be square.')
119
+ }
120
+ if (values['satin-package'] !== undefined && !values['satin-package'].trim())
121
+ throw new Error('--satin-package cannot be empty.')
122
+ try {
123
+ if ((await readdir(target)).length)
124
+ throw new Error('Destination is not empty; no files were changed.')
125
+ } catch (error) {
126
+ if (error.code !== 'ENOENT') throw error
127
+ }
128
+
129
+ const template = resolve(import.meta.dirname, '../template')
130
+ const read = path => readFile(resolve(template, path), 'utf8')
131
+ const write = (path, text) => writeFile(resolve(target, path), text)
132
+ const pkg = JSON.parse(await read('package.json'))
133
+ pkg.name = packageName
134
+ if (values['satin-package']) {
135
+ if (!pkg.dependencies?.['@satinhq/kit']) {
136
+ throw new Error('Cannot apply --satin-package: bundled package.json has no satin dependency.')
137
+ }
138
+ pkg.dependencies['@satinhq/kit'] = values['satin-package']
139
+ }
140
+ // Identity and both appearances have one definition. Encode literals safely.
141
+ let definition = await read('src/app.ts')
142
+ const replacements = [
143
+ ["id: 'my-satin-app'", `id: ${jsString(id)}`, 'id', 1],
144
+ ["'My Satin App'", jsString(name), 'name', 2],
145
+ ["background: '#f2f2f7'", `background: ${jsString(values.background)}`, 'background', 1],
146
+ [
147
+ "background: '#000000'",
148
+ `background: ${jsString(values['dark-background'])}`,
149
+ 'dark-background',
150
+ 1,
151
+ ],
152
+ ]
153
+ if (values.accent !== undefined) {
154
+ replacements.push([
155
+ 'appearance: {',
156
+ `appearance: {\n tint: ${jsString(values.accent)},`,
157
+ 'accent',
158
+ 1,
159
+ ])
160
+ }
161
+ for (const [search, , flag, count] of replacements) {
162
+ replaceRequired(definition, search, search, flag, count)
163
+ }
164
+ // Substitute in one pass so user text cannot become another template match.
165
+ const replacementByToken = new Map(
166
+ replacements.map(([search, replacement]) => [search, replacement]),
167
+ )
168
+ const tokenPattern = replacements
169
+ .map(([search]) => search.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
170
+ .join('|')
171
+ definition = definition.replace(new RegExp(tokenPattern, 'gu'), token =>
172
+ replacementByToken.get(token),
173
+ )
174
+ // Check every substitution before creating any destination files.
175
+ await mkdir(target, { recursive: true })
176
+ for (const entry of await readdir(template)) {
177
+ await cp(resolve(template, entry), resolve(target, entry), {
178
+ recursive: true,
179
+ force: false,
180
+ errorOnExist: true,
181
+ })
182
+ }
183
+ await rename(resolve(target, '_gitignore'), resolve(target, '.gitignore'))
184
+ await write('package.json', `${JSON.stringify(pkg, null, 2)}\n`)
185
+ if (suppliedIcon) {
186
+ await writeFile(resolve(target, 'public/icon-source.png'), suppliedIcon.bytes)
187
+ }
188
+ await write('src/app.ts', definition)
189
+ console.log(
190
+ `Created ${name} in ${target}.\n\nNext, in that directory:\n npm install\n npm run dev\n\nOpen the printed address at /app/ in iPhone or iPad Safari, or iOS Simulator Safari.\nFor a physical device, use a reachable trusted HTTPS address. See README.md for installed builds.`,
191
+ )
192
+ }
193
+
194
+ main().catch(error => {
195
+ console.error(`create-satin: ${error.message}`)
196
+ process.exitCode = 1
197
+ })
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "create-satin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Create a Satin iPhone Home Screen app.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": "^22.12.0 || >=24.0.0"
9
+ },
10
+ "bin": {
11
+ "create-satin": "./bin/create-satin.mjs"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "template",
16
+ "README.md",
17
+ "LICENSE",
18
+ "NOTICE",
19
+ "THIRD_PARTY_NOTICES.md"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org/",
24
+ "tag": "beta"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/satinhq/satin.git",
29
+ "directory": "packages/create-pwakit"
30
+ },
31
+ "devDependencies": {
32
+ "oxlint": "^1.79.0"
33
+ },
34
+ "homepage": "https://satin.build",
35
+ "scripts": {
36
+ "build": "node scripts/build.mjs",
37
+ "typecheck": "node --check bin/create-satin.mjs && node --check scripts/build.mjs",
38
+ "lint": "oxlint bin scripts test",
39
+ "test": "node --test test/*.test.mjs"
40
+ }
6
41
  }