@storystick/cli 0.0.0-stage → 0.2.0-rc.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.
- package/CONTENT-IMPORT.md +349 -0
- package/LICENSE +112 -0
- package/PUBLIC-API.md +30 -0
- package/README.md +263 -2
- package/assets/next-v1/app/[[...pathname]]/error.tsx +10 -0
- package/assets/next-v1/app/[[...pathname]]/not-found.tsx +8 -0
- package/assets/next-v1/app/[[...pathname]]/page.tsx +38 -0
- package/assets/next-v1/app/api/storystick/[...action]/route.ts +4 -0
- package/assets/next-v1/app/layout.tsx +28 -0
- package/assets/next-v1/app/sitemap.ts +3 -0
- package/assets/next-v1/env.local.example +13 -0
- package/assets/next-v1/globals.css +17 -0
- package/assets/next-v1/lib/site.ts +10 -0
- package/assets/next-v1/next.config.ts +4 -0
- package/assets/skills/storystick-development/SKILL.md +79 -0
- package/assets/skills/storystick-development/references/setup.md +152 -0
- package/assets/skills/storystick-development/references/story-and-next.md +49 -0
- package/dist/_backfill-recommendation.d.ts +12 -0
- package/dist/_backfill-recommendation.js +18 -0
- package/dist/asset-import.d.ts +50 -0
- package/dist/asset-import.js +160 -0
- package/dist/assistant-read-client.d.ts +3 -0
- package/dist/assistant-read-client.js +8 -0
- package/dist/auth-device.d.ts +153 -0
- package/dist/auth-device.js +390 -0
- package/dist/bootstrap-client.d.ts +52 -0
- package/dist/bootstrap-client.js +19 -0
- package/dist/cli-error.d.ts +54 -0
- package/dist/cli-error.js +107 -0
- package/dist/cli.d.ts +68 -0
- package/dist/cli.js +1520 -0
- package/dist/commands/archive.d.ts +17 -0
- package/dist/commands/archive.js +86 -0
- package/dist/commands/backfill-refs.d.ts +54 -0
- package/dist/commands/backfill-refs.js +217 -0
- package/dist/commands/connect.d.ts +11 -0
- package/dist/commands/connect.js +124 -0
- package/dist/commands/connection.d.ts +57 -0
- package/dist/commands/connection.js +83 -0
- package/dist/commands/content.d.ts +11 -0
- package/dist/commands/content.js +107 -0
- package/dist/commands/delivery.d.ts +22 -0
- package/dist/commands/delivery.js +99 -0
- package/dist/commands/info.d.ts +35 -0
- package/dist/commands/info.js +62 -0
- package/dist/commands/list.d.ts +28 -0
- package/dist/commands/list.js +116 -0
- package/dist/commands/migrate.d.ts +80 -0
- package/dist/commands/migrate.js +550 -0
- package/dist/commands/release.d.ts +8 -0
- package/dist/commands/release.js +60 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.js +58 -0
- package/dist/commands/slice-previews.d.ts +17 -0
- package/dist/commands/slice-previews.js +233 -0
- package/dist/commands/source.d.ts +82 -0
- package/dist/commands/source.js +192 -0
- package/dist/content-import.d.ts +78 -0
- package/dist/content-import.js +210 -0
- package/dist/generated-transaction.d.ts +22 -0
- package/dist/generated-transaction.js +330 -0
- package/dist/hub-client.d.ts +72 -0
- package/dist/hub-client.js +178 -0
- package/dist/init-operation.d.ts +98 -0
- package/dist/init-operation.js +427 -0
- package/dist/init-readiness.d.ts +10 -0
- package/dist/init-readiness.js +50 -0
- package/dist/init-scaffold.d.ts +54 -0
- package/dist/init-scaffold.js +439 -0
- package/dist/init.d.ts +51 -0
- package/dist/init.js +789 -0
- package/dist/mcp-execution.d.ts +15 -0
- package/dist/mcp-execution.js +90 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +41 -0
- package/dist/migration-artifact.d.ts +13 -0
- package/dist/migration-artifact.js +154 -0
- package/dist/operations-client.d.ts +137 -0
- package/dist/operations-client.js +130 -0
- package/dist/output.d.ts +347 -0
- package/dist/output.js +771 -0
- package/dist/project-access.d.ts +18 -0
- package/dist/project-access.js +1 -0
- package/dist/project-connection.d.ts +62 -0
- package/dist/project-connection.js +347 -0
- package/dist/project-execution-client.d.ts +2 -0
- package/dist/project-execution-client.js +33 -0
- package/dist/push-client.d.ts +100 -0
- package/dist/push-client.js +160 -0
- package/dist/release-context.d.ts +13 -0
- package/dist/release-context.js +39 -0
- package/dist/release-schema-client.d.ts +16 -0
- package/dist/release-schema-client.js +33 -0
- package/dist/release-sync.d.ts +36 -0
- package/dist/release-sync.js +31 -0
- package/dist/resolve.d.ts +15 -0
- package/dist/resolve.js +80 -0
- package/dist/runtime-config.d.ts +18 -0
- package/dist/runtime-config.js +86 -0
- package/dist/schema-export.d.ts +10 -0
- package/dist/schema-export.js +28 -0
- package/dist/setup.d.ts +65 -0
- package/dist/setup.js +135 -0
- package/dist/source-git.d.ts +36 -0
- package/dist/source-git.js +197 -0
- package/dist/starter-resolver.d.ts +82 -0
- package/dist/starter-resolver.js +495 -0
- package/dist/sync-transaction.d.ts +29 -0
- package/dist/sync-transaction.js +159 -0
- package/dist/transport.d.ts +8 -0
- package/dist/transport.js +73 -0
- package/examples/sanity/README.md +79 -0
- package/examples/sanity/convert.mjs +228 -0
- package/examples/sanity/fixture/data.ndjson +7 -0
- package/examples/sanity/fixture/mapping.json +44 -0
- package/examples/sanity/fixture/originals/mark.svg +1 -0
- package/examples/sanity/stories/articleBody.story +23 -0
- package/examples/sanity/stories/copy.story +7 -0
- package/examples/sanity/stories/page.story +19 -0
- package/examples/sanity/stories/picture.story +7 -0
- package/examples/sanity/stories/settings.story +9 -0
- package/examples/sanity/stories/stack.story +10 -0
- package/package.json +75 -3
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { validateWorkspaceArchive } from '@storystick/source/archive';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { dirname, join, posix, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { StorystickError, storystickErrorFromResponse } from './cli-error.js';
|
|
6
|
+
import { fetchWithDeadline } from './transport.js';
|
|
7
|
+
export const DEFAULT_STARTER_ID = 'minimal-next';
|
|
8
|
+
export const DEFAULT_STARTER_CATALOG_URL = 'https://starters.storystick.app/catalog.json';
|
|
9
|
+
export const STARTER_CATALOG_ENV = 'STORYSTICK_STARTER_CATALOG_URL';
|
|
10
|
+
export const PREPARED_STARTER_DIRECTORY = join('.storystick', 'prepared-starter-v2');
|
|
11
|
+
export const MATERIALIZED_STARTER_MARKER = join('.storystick', 'materialized-starter-v2');
|
|
12
|
+
const STARTER_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
13
|
+
const RELEASE_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
|
|
14
|
+
const INTEGRITY_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
15
|
+
const MAX_CATALOG_BYTES = 1_000_000;
|
|
16
|
+
const MAX_STARTER_FILES = 2_000;
|
|
17
|
+
const MAX_STARTER_BYTES = 50_000_000;
|
|
18
|
+
export async function loadStarterCatalog(options = {}) {
|
|
19
|
+
const startDir = options.startDir ?? process.cwd();
|
|
20
|
+
const explicitCatalogUrl = options.catalogUrl ?? process.env[STARTER_CATALOG_ENV];
|
|
21
|
+
if (!explicitCatalogUrl) {
|
|
22
|
+
const local = findLocalStarterCatalog(startDir);
|
|
23
|
+
if (local)
|
|
24
|
+
return readStarterCatalog(local);
|
|
25
|
+
}
|
|
26
|
+
const catalogUrl = normalizeCatalogUrl(explicitCatalogUrl ?? DEFAULT_STARTER_CATALOG_URL);
|
|
27
|
+
const source = (await fetchStarterBytes(catalogUrl, MAX_CATALOG_BYTES, options)).toString('utf8');
|
|
28
|
+
return parseStarterCatalog(source, catalogUrl);
|
|
29
|
+
}
|
|
30
|
+
export function readStarterCatalog(path) {
|
|
31
|
+
const source = readFileSync(path, 'utf8');
|
|
32
|
+
if (Buffer.byteLength(source) > MAX_CATALOG_BYTES) {
|
|
33
|
+
throw invalidCatalog('Starter catalog exceeds the supported size limit.');
|
|
34
|
+
}
|
|
35
|
+
return parseStarterCatalog(source, path);
|
|
36
|
+
}
|
|
37
|
+
export function selectStarter(catalog, requestedId) {
|
|
38
|
+
const id = requestedId?.trim() || catalog.default;
|
|
39
|
+
const entry = catalog.starters.find((candidate) => candidate.id === id);
|
|
40
|
+
if (!entry) {
|
|
41
|
+
throw new StorystickError(`Unknown starter "${id}". Available starters: ${catalog.starters
|
|
42
|
+
.map((candidate) => candidate.id)
|
|
43
|
+
.join(', ')}.`, { code: 'STARTER_NOT_FOUND', category: 'validation' });
|
|
44
|
+
}
|
|
45
|
+
return entry;
|
|
46
|
+
}
|
|
47
|
+
/** A required archive digest pins the bytes independently of the hosting URL. */
|
|
48
|
+
export function resolveStarterSource(entry) {
|
|
49
|
+
validateArtifactIdentity(entry);
|
|
50
|
+
return normalizeCatalogUrl(entry.source.url);
|
|
51
|
+
}
|
|
52
|
+
export function findMonorepoRoot(startDir = process.cwd()) {
|
|
53
|
+
let directory = resolve(startDir);
|
|
54
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
55
|
+
if (existsSync(join(directory, 'pnpm-workspace.yaml')))
|
|
56
|
+
return directory;
|
|
57
|
+
const parent = dirname(directory);
|
|
58
|
+
if (parent === directory)
|
|
59
|
+
return null;
|
|
60
|
+
directory = parent;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
export function findLocalStarterCatalog(startDir = process.cwd()) {
|
|
65
|
+
const root = findMonorepoRoot(startDir);
|
|
66
|
+
if (!root)
|
|
67
|
+
return null;
|
|
68
|
+
const candidate = join(root, 'starters', 'catalog.json');
|
|
69
|
+
return existsSync(candidate) && statSync(candidate).isFile()
|
|
70
|
+
? candidate
|
|
71
|
+
: null;
|
|
72
|
+
}
|
|
73
|
+
export function findLocalStarter(entry, startDir = process.cwd()) {
|
|
74
|
+
validateArtifactIdentity(entry);
|
|
75
|
+
const root = findMonorepoRoot(startDir);
|
|
76
|
+
if (!root)
|
|
77
|
+
return null;
|
|
78
|
+
const candidate = join(root, 'starters', entry.localDirectory ?? entry.id);
|
|
79
|
+
return existsSync(candidate) && statSync(candidate).isDirectory()
|
|
80
|
+
? candidate
|
|
81
|
+
: null;
|
|
82
|
+
}
|
|
83
|
+
/** Fetch, validate, and hash a starter before publishing any of its bytes. */
|
|
84
|
+
export async function prepareStarter(options) {
|
|
85
|
+
const source = resolveStarterSource(options.entry);
|
|
86
|
+
const local = findLocalStarter(options.entry, options.startDir);
|
|
87
|
+
let verifiedLocal = null;
|
|
88
|
+
if (local) {
|
|
89
|
+
try {
|
|
90
|
+
validatePreparedStarter(local, options.entry);
|
|
91
|
+
verifiedLocal = local;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// A receipt may pin an older artifact after the monorepo starter moves.
|
|
95
|
+
// The immutable remote source remains authoritative in that case.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const targetDir = resolve(options.targetDir);
|
|
99
|
+
const prepared = join(targetDir, PREPARED_STARTER_DIRECTORY);
|
|
100
|
+
if (existsSync(prepared)) {
|
|
101
|
+
const manifest = validatePreparedStarter(prepared, options.entry);
|
|
102
|
+
options.onManifest?.(manifest);
|
|
103
|
+
return manifest;
|
|
104
|
+
}
|
|
105
|
+
const internal = join(targetDir, '.storystick');
|
|
106
|
+
mkdirSync(internal, { recursive: true, mode: 0o700 });
|
|
107
|
+
const temporary = join(internal, `.prepared-starter-v2-${randomUUID()}`);
|
|
108
|
+
try {
|
|
109
|
+
if (verifiedLocal) {
|
|
110
|
+
copyDirRecursive(verifiedLocal, temporary);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const bytes = await fetchStarterBytes(source, MAX_STARTER_BYTES, options);
|
|
114
|
+
const files = validateWorkspaceArchive(bytes, options.entry.source.integrity.slice('sha256:'.length));
|
|
115
|
+
if (files.length > MAX_STARTER_FILES || files.reduce((sum, file) => sum + file.content.length, 0) > MAX_STARTER_BYTES)
|
|
116
|
+
throw starterTooLarge();
|
|
117
|
+
mkdirSync(temporary, { recursive: true });
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
const destination = join(temporary, ...file.path.split('/'));
|
|
120
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
121
|
+
writeFileSync(destination, file.content, { flag: 'wx', mode: file.mode });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const manifest = validatePreparedStarter(temporary, options.entry);
|
|
125
|
+
renameSync(temporary, prepared);
|
|
126
|
+
options.onManifest?.(manifest);
|
|
127
|
+
return manifest;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
131
|
+
if (existsSync(prepared)) {
|
|
132
|
+
return validatePreparedStarter(prepared, options.entry);
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Publish only previously validated bytes into a blank application tree. */
|
|
138
|
+
export function ensurePreparedStarterMaterialized(targetDir, entry) {
|
|
139
|
+
const root = resolve(targetDir);
|
|
140
|
+
const prepared = join(root, PREPARED_STARTER_DIRECTORY);
|
|
141
|
+
const marker = join(root, MATERIALIZED_STARTER_MARKER);
|
|
142
|
+
const manifest = validatePreparedStarter(prepared, entry);
|
|
143
|
+
if (existsSync(marker))
|
|
144
|
+
return manifest;
|
|
145
|
+
copyDirRecursive(prepared, root);
|
|
146
|
+
const temporary = `${marker}.${process.pid}.${randomUUID()}.tmp`;
|
|
147
|
+
try {
|
|
148
|
+
writeFileSync(temporary, 'complete\n', { flag: 'wx', mode: 0o600 });
|
|
149
|
+
renameSync(temporary, marker);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
rmSync(temporary, { force: true });
|
|
153
|
+
if (!existsSync(marker))
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
return manifest;
|
|
157
|
+
}
|
|
158
|
+
export function readStarterManifest(starterRoot) {
|
|
159
|
+
const path = join(starterRoot, 'storystick.starter.json');
|
|
160
|
+
if (!existsSync(path)) {
|
|
161
|
+
throw new StorystickError('Starter manifest is missing.', {
|
|
162
|
+
code: 'STARTER_MANIFEST_MISSING',
|
|
163
|
+
category: 'validation',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw invalidManifest('Starter manifest is malformed.');
|
|
172
|
+
}
|
|
173
|
+
if (!isRecord(parsed))
|
|
174
|
+
throw invalidManifest();
|
|
175
|
+
const integration = parsed.integration;
|
|
176
|
+
if (!isRecord(integration) || !isRecord(integration.fields)) {
|
|
177
|
+
throw invalidManifest();
|
|
178
|
+
}
|
|
179
|
+
const existingNext = integration.existingNext;
|
|
180
|
+
const validOverlay = parsed.framework === 'astro'
|
|
181
|
+
? existingNext === undefined
|
|
182
|
+
: Array.isArray(existingNext) && existingNext.length > 0 && existingNext.every(isOverlayPath) &&
|
|
183
|
+
new Set(existingNext.map(mapping => mapping.from)).size === existingNext.length &&
|
|
184
|
+
new Set(existingNext.map(mapping => mapping.to)).size === existingNext.length;
|
|
185
|
+
if (parsed.version !== 2 ||
|
|
186
|
+
typeof parsed.id !== 'string' ||
|
|
187
|
+
!STARTER_ID_PATTERN.test(parsed.id) ||
|
|
188
|
+
typeof parsed.release !== 'string' ||
|
|
189
|
+
!RELEASE_PATTERN.test(parsed.release) ||
|
|
190
|
+
!((parsed.framework === 'next' && integration.contract === 'next-app-router') || (parsed.framework === 'astro' && integration.contract === 'astro-native')) ||
|
|
191
|
+
integration.version !== 1 ||
|
|
192
|
+
!Array.isArray(integration.routeStoryTypes) ||
|
|
193
|
+
integration.routeStoryTypes.length === 0 ||
|
|
194
|
+
!integration.routeStoryTypes.every(isStoryIdentifier) ||
|
|
195
|
+
typeof integration.fields.title !== 'string' ||
|
|
196
|
+
!isStoryIdentifier(integration.fields.title) ||
|
|
197
|
+
typeof integration.fields.description !== 'string' ||
|
|
198
|
+
!isStoryIdentifier(integration.fields.description) ||
|
|
199
|
+
typeof integration.fields.slices !== 'string' ||
|
|
200
|
+
!isStoryIdentifier(integration.fields.slices) ||
|
|
201
|
+
typeof integration.fields.siteSettings !== 'string' ||
|
|
202
|
+
!isStoryIdentifier(integration.fields.siteSettings) ||
|
|
203
|
+
typeof integration.fields.siteName !== 'string' ||
|
|
204
|
+
!isStoryIdentifier(integration.fields.siteName) ||
|
|
205
|
+
typeof integration.fields.siteDescription !== 'string' ||
|
|
206
|
+
!isStoryIdentifier(integration.fields.siteDescription) ||
|
|
207
|
+
!validOverlay ||
|
|
208
|
+
new Set(integration.routeStoryTypes).size !==
|
|
209
|
+
integration.routeStoryTypes.length) {
|
|
210
|
+
throw invalidManifest();
|
|
211
|
+
}
|
|
212
|
+
return parsed;
|
|
213
|
+
}
|
|
214
|
+
export function computeStarterIntegrity(starterRoot) {
|
|
215
|
+
const hash = createHash('sha256');
|
|
216
|
+
const files = listStarterFiles(starterRoot);
|
|
217
|
+
if (files.length > MAX_STARTER_FILES)
|
|
218
|
+
throw starterTooLarge();
|
|
219
|
+
let totalBytes = 0;
|
|
220
|
+
for (const path of files) {
|
|
221
|
+
totalBytes += lstatSync(join(starterRoot, ...path.split('/'))).size;
|
|
222
|
+
if (totalBytes > MAX_STARTER_BYTES)
|
|
223
|
+
throw starterTooLarge();
|
|
224
|
+
hash.update(path);
|
|
225
|
+
hash.update('\0');
|
|
226
|
+
hash.update(readFileSync(join(starterRoot, ...path.split('/'))));
|
|
227
|
+
hash.update('\0');
|
|
228
|
+
}
|
|
229
|
+
return `sha256:${hash.digest('hex')}`;
|
|
230
|
+
}
|
|
231
|
+
export function preparedStarterRoot(targetDir) {
|
|
232
|
+
return join(resolve(targetDir), PREPARED_STARTER_DIRECTORY);
|
|
233
|
+
}
|
|
234
|
+
function validatePreparedStarter(root, entry) {
|
|
235
|
+
const manifest = readStarterManifest(root);
|
|
236
|
+
if (manifest.id !== entry.id ||
|
|
237
|
+
manifest.release !== entry.release ||
|
|
238
|
+
manifest.framework !== entry.framework) {
|
|
239
|
+
throw new StorystickError(`Starter manifest identity does not match catalog entry "${entry.id}@${entry.release}".`, { code: 'STARTER_IDENTITY_MISMATCH', category: 'validation' });
|
|
240
|
+
}
|
|
241
|
+
const actual = computeStarterIntegrity(root);
|
|
242
|
+
if (actual !== entry.integrity) {
|
|
243
|
+
throw new StorystickError(`Starter integrity mismatch for "${entry.id}@${entry.release}".`, {
|
|
244
|
+
code: 'STARTER_INTEGRITY_MISMATCH',
|
|
245
|
+
category: 'validation',
|
|
246
|
+
payload: { expected: entry.integrity, actual },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return manifest;
|
|
250
|
+
}
|
|
251
|
+
function parseStarterCatalog(source, origin) {
|
|
252
|
+
let parsed;
|
|
253
|
+
try {
|
|
254
|
+
parsed = JSON.parse(source);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
throw invalidCatalog(`Starter catalog is malformed: ${origin}`);
|
|
258
|
+
}
|
|
259
|
+
if (!isRecord(parsed) ||
|
|
260
|
+
parsed.version !== 2 ||
|
|
261
|
+
typeof parsed.default !== 'string' ||
|
|
262
|
+
!Array.isArray(parsed.starters) ||
|
|
263
|
+
parsed.starters.length === 0) {
|
|
264
|
+
throw invalidCatalog();
|
|
265
|
+
}
|
|
266
|
+
const starters = parsed.starters.map(validateCatalogEntry);
|
|
267
|
+
const ids = new Set(starters.map((entry) => entry.id));
|
|
268
|
+
if (ids.size !== starters.length || !ids.has(parsed.default)) {
|
|
269
|
+
throw invalidCatalog('Starter catalog IDs must be unique and include its default.');
|
|
270
|
+
}
|
|
271
|
+
return { version: 2, default: parsed.default, starters };
|
|
272
|
+
}
|
|
273
|
+
function validateCatalogEntry(value) {
|
|
274
|
+
if (!isRecord(value) || !isRecord(value.source))
|
|
275
|
+
throw invalidCatalog();
|
|
276
|
+
const source = value.source;
|
|
277
|
+
if (typeof value.id !== 'string' ||
|
|
278
|
+
!STARTER_ID_PATTERN.test(value.id) ||
|
|
279
|
+
typeof value.release !== 'string' ||
|
|
280
|
+
!RELEASE_PATTERN.test(value.release) ||
|
|
281
|
+
typeof value.label !== 'string' ||
|
|
282
|
+
value.label.trim().length === 0 ||
|
|
283
|
+
typeof value.description !== 'string' ||
|
|
284
|
+
value.description.trim().length === 0 ||
|
|
285
|
+
(value.framework !== 'next' && value.framework !== 'astro') ||
|
|
286
|
+
!Array.isArray(value.capabilities) ||
|
|
287
|
+
!value.capabilities.every((capability) => typeof capability === 'string' && STARTER_ID_PATTERN.test(capability)) ||
|
|
288
|
+
source.provider !== 'archive' ||
|
|
289
|
+
typeof source.url !== 'string' ||
|
|
290
|
+
!validDistributionUrl(source.url) ||
|
|
291
|
+
typeof source.integrity !== 'string' ||
|
|
292
|
+
!INTEGRITY_PATTERN.test(source.integrity) ||
|
|
293
|
+
typeof value.integrity !== 'string' ||
|
|
294
|
+
!INTEGRITY_PATTERN.test(value.integrity) ||
|
|
295
|
+
(value.localDirectory !== undefined &&
|
|
296
|
+
(typeof value.localDirectory !== 'string' ||
|
|
297
|
+
!isSafeRelativePath(value.localDirectory)))) {
|
|
298
|
+
throw invalidCatalog();
|
|
299
|
+
}
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
function validateArtifactIdentity(entry) {
|
|
303
|
+
const source = entry?.source;
|
|
304
|
+
if (!isRecord(entry) ||
|
|
305
|
+
!isRecord(source) ||
|
|
306
|
+
!STARTER_ID_PATTERN.test(entry.id) ||
|
|
307
|
+
!RELEASE_PATTERN.test(entry.release) ||
|
|
308
|
+
(entry.framework !== 'next' && entry.framework !== 'astro') ||
|
|
309
|
+
source.provider !== 'archive' ||
|
|
310
|
+
!validDistributionUrl(source.url) ||
|
|
311
|
+
!INTEGRITY_PATTERN.test(source.integrity) ||
|
|
312
|
+
!INTEGRITY_PATTERN.test(entry.integrity) ||
|
|
313
|
+
(entry.localDirectory !== undefined &&
|
|
314
|
+
!isSafeRelativePath(entry.localDirectory))) {
|
|
315
|
+
throw new StorystickError(`Starter artifact identity is invalid for "${entry?.id ?? 'unknown'}".`, { code: 'STARTER_IDENTITY_INVALID', category: 'validation' });
|
|
316
|
+
}
|
|
317
|
+
return entry;
|
|
318
|
+
}
|
|
319
|
+
function normalizeCatalogUrl(raw) {
|
|
320
|
+
let url;
|
|
321
|
+
try {
|
|
322
|
+
url = new URL(raw);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
throw invalidCatalog(`${STARTER_CATALOG_ENV} must be an HTTPS URL.`);
|
|
326
|
+
}
|
|
327
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))) || url.username || url.password || url.search || url.hash) {
|
|
328
|
+
throw invalidCatalog(`${STARTER_CATALOG_ENV} must be an HTTPS URL.`);
|
|
329
|
+
}
|
|
330
|
+
return url.href;
|
|
331
|
+
}
|
|
332
|
+
function validDistributionUrl(value) {
|
|
333
|
+
try {
|
|
334
|
+
normalizeCatalogUrl(value);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Bound headers, streamed bytes and elapsed time before parsing or writing. */
|
|
342
|
+
async function fetchStarterBytes(url, maxBytes, options) {
|
|
343
|
+
const signal = AbortSignal.any([AbortSignal.timeout(15_000), ...(options.signal ? [options.signal] : [])]);
|
|
344
|
+
const response = await fetchWithDeadline(url, { redirect: 'error' }, { fetchImpl: options.fetchImpl, signal });
|
|
345
|
+
if (!response.ok)
|
|
346
|
+
throw await storystickErrorFromResponse(response);
|
|
347
|
+
if (response.url && normalizeCatalogUrl(response.url) !== normalizeCatalogUrl(url)) {
|
|
348
|
+
await response.body?.cancel();
|
|
349
|
+
throw invalidCatalog('Starter distribution must not redirect.');
|
|
350
|
+
}
|
|
351
|
+
if (Number(response.headers.get('content-length')) > maxBytes || !response.body) {
|
|
352
|
+
await response.body?.cancel();
|
|
353
|
+
throw starterTooLarge();
|
|
354
|
+
}
|
|
355
|
+
const reader = response.body.getReader();
|
|
356
|
+
const cancel = () => { void reader.cancel().catch(() => { }); };
|
|
357
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
358
|
+
const chunks = [];
|
|
359
|
+
let size = 0;
|
|
360
|
+
try {
|
|
361
|
+
while (true) {
|
|
362
|
+
signal.throwIfAborted();
|
|
363
|
+
const chunk = await reader.read();
|
|
364
|
+
signal.throwIfAborted();
|
|
365
|
+
if (chunk.done)
|
|
366
|
+
break;
|
|
367
|
+
size += chunk.value.byteLength;
|
|
368
|
+
if (size > maxBytes)
|
|
369
|
+
throw starterTooLarge();
|
|
370
|
+
chunks.push(chunk.value);
|
|
371
|
+
}
|
|
372
|
+
return Buffer.concat(chunks, size);
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
signal.removeEventListener('abort', cancel);
|
|
376
|
+
await reader.cancel();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function invalidManifest(message = 'Starter manifest must use a supported Next or Astro integration contract V2.') {
|
|
380
|
+
return new StorystickError(message, {
|
|
381
|
+
code: 'STARTER_MANIFEST_INVALID',
|
|
382
|
+
category: 'validation',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
function invalidCatalog(message = 'Starter catalog does not match the supported V1 contract.') {
|
|
386
|
+
return new StorystickError(message, {
|
|
387
|
+
code: 'STARTER_CATALOG_INVALID',
|
|
388
|
+
category: 'validation',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
function isRecord(value) {
|
|
392
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
393
|
+
}
|
|
394
|
+
function isStoryIdentifier(value) {
|
|
395
|
+
return typeof value === 'string' && /^[A-Za-z][A-Za-z0-9]*$/u.test(value);
|
|
396
|
+
}
|
|
397
|
+
function isOverlayPath(value) {
|
|
398
|
+
return (isRecord(value) &&
|
|
399
|
+
typeof value.from === 'string' &&
|
|
400
|
+
value.from !== '.' &&
|
|
401
|
+
isSafeRelativePath(value.from) &&
|
|
402
|
+
typeof value.to === 'string' &&
|
|
403
|
+
isSafeRelativePath(value.to) &&
|
|
404
|
+
(value.to === 'stories' || value.to.startsWith('components/')));
|
|
405
|
+
}
|
|
406
|
+
function isSafeRelativePath(value) {
|
|
407
|
+
if (!value || value.includes('\\'))
|
|
408
|
+
return false;
|
|
409
|
+
const normalized = posix.normalize(value);
|
|
410
|
+
return (normalized === value &&
|
|
411
|
+
!normalized.startsWith('../') &&
|
|
412
|
+
normalized !== '..' &&
|
|
413
|
+
!normalized.startsWith('/') &&
|
|
414
|
+
!normalized.includes('/../'));
|
|
415
|
+
}
|
|
416
|
+
export function listStarterFiles(root) {
|
|
417
|
+
const files = [];
|
|
418
|
+
const visit = (directory) => {
|
|
419
|
+
for (const entry of readdirSync(directory).sort()) {
|
|
420
|
+
if (ignoredStarterEntry(entry))
|
|
421
|
+
continue;
|
|
422
|
+
const absolute = join(directory, entry);
|
|
423
|
+
const status = lstatSync(absolute);
|
|
424
|
+
if (status.isSymbolicLink())
|
|
425
|
+
throw unsafeStarterArtifact(absolute);
|
|
426
|
+
if (status.isDirectory())
|
|
427
|
+
visit(absolute);
|
|
428
|
+
else if (status.isFile()) {
|
|
429
|
+
files.push(relative(root, absolute).split(sep).join('/'));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
visit(resolve(root));
|
|
434
|
+
return files.sort();
|
|
435
|
+
}
|
|
436
|
+
export function copyDirRecursive(source, destination) {
|
|
437
|
+
mkdirSync(destination, { recursive: true });
|
|
438
|
+
for (const entry of readdirSync(source)) {
|
|
439
|
+
if (ignoredStarterEntry(entry))
|
|
440
|
+
continue;
|
|
441
|
+
const from = join(source, entry);
|
|
442
|
+
const to = join(destination, entry);
|
|
443
|
+
const status = lstatSync(from);
|
|
444
|
+
if (status.isSymbolicLink())
|
|
445
|
+
throw unsafeStarterArtifact(from);
|
|
446
|
+
if (status.isDirectory()) {
|
|
447
|
+
if (existsSync(to)) {
|
|
448
|
+
const destinationStatus = lstatSync(to);
|
|
449
|
+
if (destinationStatus.isSymbolicLink() ||
|
|
450
|
+
!destinationStatus.isDirectory()) {
|
|
451
|
+
throw starterCollision(to);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
copyDirRecursive(from, to);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!status.isFile())
|
|
458
|
+
continue;
|
|
459
|
+
if (existsSync(to)) {
|
|
460
|
+
const destinationStatus = lstatSync(to);
|
|
461
|
+
if (destinationStatus.isSymbolicLink() ||
|
|
462
|
+
!destinationStatus.isFile()) {
|
|
463
|
+
throw starterCollision(to);
|
|
464
|
+
}
|
|
465
|
+
if (readFileSync(to).equals(readFileSync(from)))
|
|
466
|
+
continue;
|
|
467
|
+
throw starterCollision(to);
|
|
468
|
+
}
|
|
469
|
+
copyFileSync(from, to);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function ignoredStarterEntry(entry) {
|
|
473
|
+
return [
|
|
474
|
+
'node_modules',
|
|
475
|
+
'.next',
|
|
476
|
+
'_generated',
|
|
477
|
+
'.env.local',
|
|
478
|
+
'generated',
|
|
479
|
+
'convex',
|
|
480
|
+
'.git',
|
|
481
|
+
'.storystick',
|
|
482
|
+
].includes(entry);
|
|
483
|
+
}
|
|
484
|
+
function starterCollision(path) {
|
|
485
|
+
return new StorystickError(`Starter refuses to overwrite existing path: ${path}`, { code: 'STARTER_TARGET_COLLISION', category: 'conflict' });
|
|
486
|
+
}
|
|
487
|
+
function unsafeStarterArtifact(path) {
|
|
488
|
+
return new StorystickError(`Starter contains an unsupported link: ${path}`, {
|
|
489
|
+
code: 'STARTER_ARTIFACT_UNSAFE',
|
|
490
|
+
category: 'validation',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
function starterTooLarge() {
|
|
494
|
+
return new StorystickError(`Starter exceeds ${MAX_STARTER_FILES} files or ${MAX_STARTER_BYTES} bytes.`, { code: 'STARTER_ARTIFACT_TOO_LARGE', category: 'validation' });
|
|
495
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SchemaPushResult } from './push-client.js';
|
|
2
|
+
type SyncPushResult = Omit<SchemaPushResult, 'projectionSeq'> & {
|
|
3
|
+
projectionSeq?: number;
|
|
4
|
+
};
|
|
5
|
+
export interface SyncOperationReceipt {
|
|
6
|
+
version: 1;
|
|
7
|
+
target?: string;
|
|
8
|
+
state: 'prepared' | 'pushed';
|
|
9
|
+
rootDir: string;
|
|
10
|
+
outputDirectory: string;
|
|
11
|
+
schemaVersion: string;
|
|
12
|
+
generationId: string;
|
|
13
|
+
storyCount: number;
|
|
14
|
+
pushResult?: SyncPushResult;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function readSyncOperation(rootDir: string): SyncOperationReceipt | null;
|
|
19
|
+
export declare function writePreparedSyncOperation(args: {
|
|
20
|
+
rootDir: string;
|
|
21
|
+
outputDirectory: string;
|
|
22
|
+
schemaVersion: string;
|
|
23
|
+
generationId: string;
|
|
24
|
+
storyCount: number;
|
|
25
|
+
target?: string;
|
|
26
|
+
}): SyncOperationReceipt;
|
|
27
|
+
export declare function markSyncOperationPushed(receipt: SyncOperationReceipt, pushResult: SyncPushResult): SyncOperationReceipt;
|
|
28
|
+
export declare function clearSyncOperation(rootDir: string): void;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
const SYNC_RECEIPT_PATH = join('.storystick', 'sync-operation.json');
|
|
5
|
+
export function readSyncOperation(rootDir) {
|
|
6
|
+
const path = receiptPath(rootDir);
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return null;
|
|
9
|
+
const source = readFileSync(path, 'utf8');
|
|
10
|
+
assertSecretFree(source);
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(source);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error('Sync operation receipt is invalid.');
|
|
17
|
+
}
|
|
18
|
+
if (!isReceipt(parsed) ||
|
|
19
|
+
resolve(parsed.rootDir) !== resolve(rootDir) ||
|
|
20
|
+
(parsed.state === 'pushed' &&
|
|
21
|
+
parsed.pushResult?.version !== parsed.schemaVersion)) {
|
|
22
|
+
throw new Error('Sync operation receipt is invalid.');
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
export function writePreparedSyncOperation(args) {
|
|
27
|
+
const existing = readSyncOperation(args.rootDir);
|
|
28
|
+
if (existing?.state === 'pushed') {
|
|
29
|
+
throw new Error('A pushed schema is waiting for its local generation commit. Resume sync first.');
|
|
30
|
+
}
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const receipt = {
|
|
33
|
+
version: 1,
|
|
34
|
+
state: 'prepared',
|
|
35
|
+
...(args.target ? { target: args.target } : {}),
|
|
36
|
+
rootDir: resolve(args.rootDir),
|
|
37
|
+
outputDirectory: resolve(args.outputDirectory),
|
|
38
|
+
schemaVersion: args.schemaVersion,
|
|
39
|
+
generationId: args.generationId,
|
|
40
|
+
storyCount: args.storyCount,
|
|
41
|
+
createdAt: existing?.schemaVersion === args.schemaVersion &&
|
|
42
|
+
existing.generationId === args.generationId
|
|
43
|
+
? existing.createdAt
|
|
44
|
+
: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
};
|
|
47
|
+
writeReceipt(receipt);
|
|
48
|
+
return receipt;
|
|
49
|
+
}
|
|
50
|
+
export function markSyncOperationPushed(receipt, pushResult) {
|
|
51
|
+
if (pushResult.version !== receipt.schemaVersion) {
|
|
52
|
+
throw new Error('Schema push result does not match the prepared sync.');
|
|
53
|
+
}
|
|
54
|
+
const next = {
|
|
55
|
+
...receipt,
|
|
56
|
+
state: 'pushed',
|
|
57
|
+
pushResult,
|
|
58
|
+
updatedAt: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
writeReceipt(next);
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
export function clearSyncOperation(rootDir) {
|
|
64
|
+
rmSync(receiptPath(rootDir), { force: true });
|
|
65
|
+
}
|
|
66
|
+
function receiptPath(rootDir) {
|
|
67
|
+
return join(resolve(rootDir), SYNC_RECEIPT_PATH);
|
|
68
|
+
}
|
|
69
|
+
function writeReceipt(receipt) {
|
|
70
|
+
const path = receiptPath(receipt.rootDir);
|
|
71
|
+
const directory = dirname(path);
|
|
72
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
73
|
+
const serialized = `${JSON.stringify(receipt, null, 2)}\n`;
|
|
74
|
+
assertSecretFree(serialized);
|
|
75
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
76
|
+
let handle;
|
|
77
|
+
try {
|
|
78
|
+
handle = openSync(temporary, 'wx', 0o600);
|
|
79
|
+
writeFileSync(handle, serialized, 'utf8');
|
|
80
|
+
fsyncSync(handle);
|
|
81
|
+
closeSync(handle);
|
|
82
|
+
handle = undefined;
|
|
83
|
+
renameSync(temporary, path);
|
|
84
|
+
chmodSync(path, 0o600);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (handle !== undefined)
|
|
88
|
+
closeSync(handle);
|
|
89
|
+
try {
|
|
90
|
+
if (existsSync(temporary))
|
|
91
|
+
unlinkSync(temporary);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Preserve the original write failure.
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function isReceipt(value) {
|
|
100
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const receipt = value;
|
|
104
|
+
const allowedKeys = new Set([
|
|
105
|
+
'target',
|
|
106
|
+
'version',
|
|
107
|
+
'state',
|
|
108
|
+
'rootDir',
|
|
109
|
+
'outputDirectory',
|
|
110
|
+
'schemaVersion',
|
|
111
|
+
'generationId',
|
|
112
|
+
'storyCount',
|
|
113
|
+
'pushResult',
|
|
114
|
+
'createdAt',
|
|
115
|
+
'updatedAt',
|
|
116
|
+
]);
|
|
117
|
+
return (Object.keys(receipt).every((key) => allowedKeys.has(key)) &&
|
|
118
|
+
receipt.version === 1 &&
|
|
119
|
+
(receipt.target === undefined || typeof receipt.target === 'string') &&
|
|
120
|
+
(receipt.state === 'prepared' || receipt.state === 'pushed') &&
|
|
121
|
+
typeof receipt.rootDir === 'string' &&
|
|
122
|
+
typeof receipt.outputDirectory === 'string' &&
|
|
123
|
+
typeof receipt.schemaVersion === 'string' &&
|
|
124
|
+
/^g-[a-f0-9]{24}$/u.test(String(receipt.generationId)) &&
|
|
125
|
+
typeof receipt.storyCount === 'number' &&
|
|
126
|
+
Number.isSafeInteger(receipt.storyCount) &&
|
|
127
|
+
typeof receipt.createdAt === 'number' &&
|
|
128
|
+
typeof receipt.updatedAt === 'number' &&
|
|
129
|
+
(receipt.state !== 'pushed' || isPushResult(receipt.pushResult)));
|
|
130
|
+
}
|
|
131
|
+
function isPushResult(value) {
|
|
132
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const result = value;
|
|
136
|
+
if ((result.status !== 'pushed' && result.status !== 'unchanged') ||
|
|
137
|
+
typeof result.version !== 'string' ||
|
|
138
|
+
!Array.isArray(result.newBindingNotes)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (!result.newBindingNotes.every((note) => typeof note === 'object' &&
|
|
142
|
+
note !== null &&
|
|
143
|
+
!Array.isArray(note) &&
|
|
144
|
+
['docType', 'sliceType', 'sliceField', 'sourcePath', 'message'].every((key) => typeof note[key] === 'string'))) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const recommendation = result.recommendBackfill;
|
|
148
|
+
return recommendation === undefined || (typeof recommendation === 'object' &&
|
|
149
|
+
recommendation !== null &&
|
|
150
|
+
!Array.isArray(recommendation) &&
|
|
151
|
+
typeof recommendation.command === 'string' &&
|
|
152
|
+
typeof recommendation.reason === 'string');
|
|
153
|
+
}
|
|
154
|
+
function assertSecretFree(serialized) {
|
|
155
|
+
if (/ss_(?:sk|pk)_(?:live|test)_/u.test(serialized) ||
|
|
156
|
+
/"(?:apiKey|publishableKey|token)"\s*:/u.test(serialized)) {
|
|
157
|
+
throw new Error('Refusing to persist secrets in sync receipt.');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
2
|
+
export interface FetchWithDeadlineOptions {
|
|
3
|
+
fetchImpl?: typeof fetch;
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function fetchWithDeadline(input: string | URL | Request, init?: RequestInit, options?: FetchWithDeadlineOptions): Promise<Response>;
|
|
8
|
+
export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
|