@storystick/source 0.0.0-stage → 0.1.0-rc.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,112 @@
1
+ # Functional Source License, Version 1.1, MIT Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-MIT
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Tanguy Freycon
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the MIT license that is effective on the second anniversary of the date we make
91
+ the Software available. On or after that date, you may use the Software under
92
+ the MIT license, in which case the following will apply:
93
+
94
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
95
+ this software and associated documentation files (the "Software"), to deal in
96
+ the Software without restriction, including without limitation the rights to
97
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
98
+ of the Software, and to permit persons to whom the Software is furnished to do
99
+ so, subject to the following conditions:
100
+
101
+ The above copyright notice and this permission notice shall be included in all
102
+ copies or substantial portions of the Software.
103
+
104
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
105
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
106
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
107
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
108
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
109
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
110
+ SOFTWARE.
111
+
112
+ This Agreement shall be governed by the laws of France.
package/PUBLIC-API.md ADDED
@@ -0,0 +1,19 @@
1
+ # @storystick/source API contract
2
+
3
+ Install this candidate explicitly with `@storystick/source@0.1.0-rc.1` after registry bootstrap and publication. This repository alone does not make the candidate or beta channel available on npm.
4
+
5
+ Version 0.1.0-rc.1, cohort `sdk-0.2.0-rc.1`. ESM distribution; no CommonJS or universal Edge compatibility is promised.
6
+
7
+ This package supports the integration facades; installing it directly is an advanced use. Reserved contracts are required between SDK packages and must be upgraded with the same cohort. Their public export paths do not promise independent stability.
8
+
9
+ | Entry | Status |
10
+ | --- | --- |
11
+ | `./manifest` | Supported beta entry |
12
+ | `./archive` | Supported beta entry |
13
+ | `./recipe` | Supported beta entry |
14
+
15
+ The qualified toolchain is Node 24.19.0 and pnpm 9.15.9. Next uses App Router 15.5.28, Sharp 0.35.5 and React/React DOM 19.2.8; Astro uses 7.4.4 with the Node adapter 11.1.9. Browser delivery is verified by bundling; hosted Preview activation and cross-browser behavior require the staging manual check. Source/CLI are Node tools, server entries belong on the server, and browser entries must never receive a server API key.
16
+
17
+ This is a support/tooling contract. It does not independently grant project access or permission to perform effects.
18
+
19
+ The repository is private. Public package availability does not make the monorepo public, and npm provenance from that repository is unavailable. Product and pilot support: [Storystick](https://storystick.app).
package/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # @storystick/source
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
+ Source manifests, deterministic archives and execution-recipe validation shared by the Storystick CLI and workspace services. This ESM package targets Node.js.
4
+
5
+ Import the supported entry points:
6
+
7
+ ```ts
8
+ import { workspaceRecipe } from '@storystick/source/recipe'
9
+ import { encodeSourceArchive, sourceDigest } from '@storystick/source/archive'
10
+ ```
11
+
12
+ `@storystick/source/manifest` defines source paths and scopes. Archive helpers preserve content digests; recipe validation describes commands and does not execute or authorize them. Callers remain responsible for access checks, resource admission and execution isolation.
13
+
14
+ License: FSL-1.1-MIT; see LICENSE distributed with this package.
15
+
16
+ ## Candidate distribution contract
17
+
18
+ See [PUBLIC-API.md](./PUBLIC-API.md) for supported entries, reserved SDK contracts,
19
+ the qualified runtime matrix and the candidate cohort. Upgrade Storystick
20
+ packages together.
@@ -0,0 +1,24 @@
1
+ export { sourceManifest } from './manifest.js';
2
+ export type SourceEntry = {
3
+ path: string;
4
+ content: Buffer;
5
+ mode: number;
6
+ digest: string;
7
+ };
8
+ export declare const sourceDigest: (value: string | Uint8Array) => string;
9
+ export declare const sourceRevision: (files: SourceEntry[]) => string;
10
+ /** Parse before uploading anything. No tar subprocess can reinterpret an unchecked header. */
11
+ export declare function validateWorkspaceArchive(archive: Buffer, archiveDigest: string): SourceEntry[];
12
+ /** A portable source checkpoint. Dependencies/build caches remain provider caches.
13
+ * Paths, modes and bytes (including binary assets) survive without a Git host. */
14
+ export declare function encodeTar(files: {
15
+ path: string;
16
+ content: Buffer;
17
+ mode: number;
18
+ }[]): Buffer<ArrayBuffer>;
19
+ export declare function encodeSourceArchive(files: SourceEntry[]): NonSharedBuffer;
20
+ export declare function editSource(files: SourceEntry[], input: {
21
+ file: string;
22
+ expectedDigest: string | null;
23
+ text: string;
24
+ }): SourceEntry[];
@@ -0,0 +1,171 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { gzipSync, gunzipSync } from 'node:zlib';
3
+ import { posix } from 'node:path';
4
+ import { sourcePath, sourceIdentity, sourceManifest, MAX_ARCHIVE_BYTES, MAX_SOURCE_BYTES, MAX_SOURCE_FILES } from './manifest.js';
5
+ export { sourceManifest } from './manifest.js';
6
+ export const sourceDigest = (value) => createHash('sha256').update(value).digest('hex');
7
+ export const sourceRevision = (files) => sourceDigest(sourceIdentity(files));
8
+ const DIGEST = /^[a-f0-9]{64}$/;
9
+ const hash = sourceDigest;
10
+ function fail(code) { throw Error(code); }
11
+ function octal(bytes) {
12
+ const text = bytes.toString('ascii').replace(/\0.*$/, '').trim();
13
+ if (!/^[0-7]*$/.test(text))
14
+ fail('WORKSPACE_ARCHIVE_INVALID');
15
+ const n = text ? Number.parseInt(text, 8) : 0;
16
+ if (!Number.isSafeInteger(n) || n < 0)
17
+ fail('WORKSPACE_ARCHIVE_INVALID');
18
+ return n;
19
+ }
20
+ function tarText(bytes) { return bytes.toString('utf8').replace(/\0.*$/, ''); }
21
+ function pax(data) {
22
+ const fields = {};
23
+ let offset = 0;
24
+ while (offset < data.length) {
25
+ const space = data.indexOf(32, offset);
26
+ const digits = data.subarray(offset, space).toString('ascii');
27
+ if (space < offset || !/^[1-9][0-9]*$/.test(digits))
28
+ fail('WORKSPACE_ARCHIVE_INVALID');
29
+ const length = Number(digits);
30
+ if (!Number.isSafeInteger(length) || length <= space - offset + 1 || offset + length > data.length || data[offset + length - 1] !== 10)
31
+ fail('WORKSPACE_ARCHIVE_INVALID');
32
+ const line = data.subarray(space + 1, offset + length - 1).toString('utf8');
33
+ const eq = line.indexOf('=');
34
+ if (eq <= 0)
35
+ fail('WORKSPACE_ARCHIVE_INVALID');
36
+ const key = line.slice(0, eq);
37
+ if (!['path', 'mtime', 'atime', 'ctime', 'comment', 'uid', 'gid', 'uname', 'gname'].includes(key))
38
+ fail('WORKSPACE_ARCHIVE_METADATA_UNSUPPORTED');
39
+ fields[key] = line.slice(eq + 1);
40
+ offset += length;
41
+ }
42
+ return fields;
43
+ }
44
+ /** Parse before uploading anything. No tar subprocess can reinterpret an unchecked header. */
45
+ export function validateWorkspaceArchive(archive, archiveDigest) {
46
+ if (!DIGEST.test(archiveDigest) || hash(archive) !== archiveDigest)
47
+ fail('WORKSPACE_ARCHIVE_DIGEST_MISMATCH');
48
+ if (archive.length > MAX_ARCHIVE_BYTES)
49
+ fail('WORKSPACE_ARCHIVE_TOO_LARGE');
50
+ let data;
51
+ try {
52
+ data = archive[0] === 0x1f && archive[1] === 0x8b ? gunzipSync(archive, { maxOutputLength: MAX_SOURCE_BYTES }) : archive;
53
+ }
54
+ catch {
55
+ fail('WORKSPACE_ARCHIVE_INVALID');
56
+ }
57
+ if (data.length > MAX_SOURCE_BYTES || data.length % 512 !== 0)
58
+ fail('WORKSPACE_ARCHIVE_INVALID');
59
+ const files = [];
60
+ const seen = new Map();
61
+ let metadata = {};
62
+ let ended = false;
63
+ for (let offset = 0; offset < data.length;) {
64
+ const header = data.subarray(offset, offset + 512);
65
+ if (header.every(byte => byte === 0)) {
66
+ if (!data.subarray(offset).every(byte => byte === 0))
67
+ fail('WORKSPACE_ARCHIVE_TRAILING_DATA');
68
+ ended = true;
69
+ break;
70
+ }
71
+ let sum = 0;
72
+ for (let i = 0; i < 512; i++)
73
+ sum += i >= 148 && i < 156 ? 32 : header[i];
74
+ if (sum !== octal(header.subarray(148, 156)))
75
+ fail('WORKSPACE_ARCHIVE_CHECKSUM_INVALID');
76
+ const size = octal(header.subarray(124, 136));
77
+ const end = offset + 512 + size;
78
+ if (end > data.length)
79
+ fail('WORKSPACE_ARCHIVE_INVALID');
80
+ const body = data.subarray(offset + 512, end);
81
+ offset += 512 + Math.ceil(size / 512) * 512;
82
+ const type = String.fromCharCode(header[156]);
83
+ if (type === 'x' || type === 'g') {
84
+ const fields = pax(body);
85
+ if (type === 'g' && fields.path)
86
+ fail('WORKSPACE_ARCHIVE_INVALID');
87
+ if (type === 'x')
88
+ metadata = fields;
89
+ continue;
90
+ }
91
+ if (!['0', '\0', '5'].includes(type))
92
+ fail('WORKSPACE_ARCHIVE_LINK_OR_SPECIAL_FILE');
93
+ const prefix = tarText(header.subarray(345, 500));
94
+ let path = metadata.path ?? [prefix, tarText(header.subarray(0, 100))].filter(Boolean).join('/');
95
+ metadata = {};
96
+ if (type === '5')
97
+ path = path.replace(/\/$/, '');
98
+ sourcePath(path);
99
+ if (seen.has(path))
100
+ fail('WORKSPACE_ARCHIVE_DUPLICATE_PATH');
101
+ for (let parent = posix.dirname(path); parent !== '.'; parent = posix.dirname(parent)) {
102
+ if (seen.get(parent) === 'file')
103
+ fail('WORKSPACE_ARCHIVE_PATH_COLLISION');
104
+ }
105
+ if (type !== '5' && [...seen.keys()].some(other => other.startsWith(`${path}/`)))
106
+ fail('WORKSPACE_ARCHIVE_PATH_COLLISION');
107
+ seen.set(path, type === '5' ? 'directory' : 'file');
108
+ if (seen.size > MAX_SOURCE_FILES)
109
+ fail('WORKSPACE_ARCHIVE_TOO_MANY_FILES');
110
+ if (type === '5') {
111
+ if (size !== 0)
112
+ fail('WORKSPACE_ARCHIVE_INVALID');
113
+ continue;
114
+ }
115
+ files.push({ path, content: body, digest: hash(body), mode: octal(header.subarray(100, 108)) & 0o111 ? 0o755 : 0o644 });
116
+ }
117
+ if (!ended || Object.keys(metadata).length)
118
+ fail('WORKSPACE_ARCHIVE_INVALID');
119
+ sourceManifest(files);
120
+ return files;
121
+ }
122
+ /** A portable source checkpoint. Dependencies/build caches remain provider caches.
123
+ * Paths, modes and bytes (including binary assets) survive without a Git host. */
124
+ // This function is embedded in the provider checkpoint program. Keep it closed
125
+ // over built-ins only, with no nested named functions: keepNames bundling adds
126
+ // an outer helper reference to those functions, which does not cross the wire.
127
+ export function encodeTar(files) {
128
+ const chunks = [];
129
+ for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
130
+ const entries = [];
131
+ if (Buffer.byteLength(file.path) > 100) {
132
+ const record = `path=${file.path}\n`;
133
+ let length = Buffer.byteLength(record) + 2;
134
+ while (Buffer.byteLength(`${length} ${record}`) !== length)
135
+ length = Buffer.byteLength(`${length} ${record}`);
136
+ entries.push({ path: 'PaxHeader', content: Buffer.from(`${length} ${record}`), mode: 0o644, type: 'x' });
137
+ }
138
+ entries.push({ path: Buffer.byteLength(file.path) > 100 ? 'source' : file.path,
139
+ content: file.content, mode: file.mode, type: '0' });
140
+ for (const { path, content, mode, type } of entries) {
141
+ const header = Buffer.alloc(512);
142
+ header.write(path, 0, 100);
143
+ header.write(mode.toString(8).padStart(7, '0') + '\0', 100, 8);
144
+ header.write(content.length.toString(8).padStart(11, '0') + '\0', 124, 12);
145
+ header.fill(32, 148, 156);
146
+ header.write(type, 156, 1);
147
+ header.write('ustar\0', 257, 6);
148
+ header.write(header.reduce((a, b) => a + b, 0).toString(8).padStart(6, '0') + '\0 ', 148, 8);
149
+ chunks.push(header, content, Buffer.alloc((512 - content.length % 512) % 512));
150
+ }
151
+ }
152
+ return Buffer.concat([...chunks, Buffer.alloc(1024)]);
153
+ }
154
+ export function encodeSourceArchive(files) {
155
+ sourceManifest(files);
156
+ if (files.reduce((size, file) => size + file.content.length, 0) > MAX_SOURCE_BYTES)
157
+ throw Error('WORKSPACE_ARCHIVE_TOO_LARGE');
158
+ if (files.some(file => sourceDigest(file.content) !== file.digest))
159
+ throw Error('WORKSPACE_SOURCE_DIGEST_MISMATCH');
160
+ const archive = gzipSync(encodeTar(files));
161
+ validateWorkspaceArchive(archive, sourceDigest(archive));
162
+ return archive;
163
+ }
164
+ export function editSource(files, input) {
165
+ const previous = files.find(file => file.path === input.file);
166
+ if ((previous?.digest ?? null) !== input.expectedDigest)
167
+ throw Error('PROJECT_WORKSPACE_FILE_CHANGED');
168
+ const content = Buffer.from(input.text);
169
+ const replacement = { path: input.file, content, digest: sourceDigest(content), mode: previous?.mode ?? 0o644 };
170
+ return [...files.filter(file => file.path !== input.file), replacement];
171
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ export declare const digest: z.ZodString;
3
+ export declare const gitCommit: z.ZodString;
4
+ export declare const MAX_SOURCE_FILES = 20000;
5
+ export declare const MAX_SOURCE_BYTES: number;
6
+ export declare const MAX_ARCHIVE_BYTES: number;
7
+ /** Shared by imports, archives, sandbox writes and restitutions. */
8
+ export declare function sourcePath(value: string): string;
9
+ export declare const sourceScope: z.ZodObject<{
10
+ appRoot: z.ZodString;
11
+ include: z.ZodArray<z.ZodString>;
12
+ exclude: z.ZodArray<z.ZodString>;
13
+ }, z.core.$strict>;
14
+ export type SourceScope = z.infer<typeof sourceScope>;
15
+ export declare const defaultSourceScope: SourceScope;
16
+ export declare function inSourceScope(path: string, scope: SourceScope): boolean;
17
+ export type SourceFile = {
18
+ path: string;
19
+ digest: string;
20
+ mode: number;
21
+ };
22
+ export declare function sourceManifest(files: SourceFile[]): SourceFile[];
23
+ export declare const sourceIdentity: (files: SourceFile[]) => string;
24
+ export declare function sourceChanges(before: SourceFile[], after: SourceFile[]): {
25
+ file: string;
26
+ change: "created" | "deleted" | "modified";
27
+ beforeDigest: string | null;
28
+ digest: string | null;
29
+ beforeMode: number | null;
30
+ mode: number | null;
31
+ }[];
32
+ export declare const sourceOrigin: z.ZodObject<{
33
+ commit: z.ZodString;
34
+ }, z.core.$strict>;
35
+ export declare const snapshotProof: z.ZodObject<{
36
+ revision: z.ZodString;
37
+ archiveDigest: z.ZodString;
38
+ }, z.core.$strict>;
39
+ export declare const sourceChange: z.ZodObject<{
40
+ format: z.ZodLiteral<1>;
41
+ projectShortId: z.ZodString;
42
+ workspaceId: z.ZodString;
43
+ releaseId: z.ZodString;
44
+ scope: z.ZodObject<{
45
+ appRoot: z.ZodString;
46
+ include: z.ZodArray<z.ZodString>;
47
+ exclude: z.ZodArray<z.ZodString>;
48
+ }, z.core.$strict>;
49
+ origin: z.ZodOptional<z.ZodObject<{
50
+ commit: z.ZodString;
51
+ }, z.core.$strict>>;
52
+ base: z.ZodObject<{
53
+ revision: z.ZodString;
54
+ archiveDigest: z.ZodString;
55
+ }, z.core.$strict>;
56
+ result: z.ZodObject<{
57
+ revision: z.ZodString;
58
+ archiveDigest: z.ZodString;
59
+ }, z.core.$strict>;
60
+ }, z.core.$strict>;
61
+ export type SourceChange = z.infer<typeof sourceChange>;
62
+ /** Object field order can change through storage and JSON transports. */
63
+ export declare const sourceChangeIdentity: (change: SourceChange) => string;
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ export const digest = z.string().regex(/^[a-f0-9]{64}$/);
3
+ export const gitCommit = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/);
4
+ export const MAX_SOURCE_FILES = 20_000;
5
+ export const MAX_SOURCE_BYTES = 256 * 1024 * 1024;
6
+ export const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
7
+ /** Shared by imports, archives, sandbox writes and restitutions. */
8
+ export function sourcePath(value) {
9
+ if (!value || value.length > 1024 || value.startsWith('/') || /[\\\x00-\x1f\x7f:]/.test(value))
10
+ throw Error('WORKSPACE_PATH_INVALID');
11
+ const parts = value.split('/');
12
+ if (parts.some(part => !part || part === '.' || part === '..' ||
13
+ (/[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(part)) ||
14
+ ['.git', '.ssh', '.aws', '.vercel', '.storystick', '.npmrc', '.netrc', '.pypirc', '.yarnrc.yml'].includes(part.toLowerCase()) ||
15
+ part.toLowerCase().startsWith('.env') || /^(id_rsa|id_ed25519|credentials)(\.|$)/i.test(part) || /\.(pem|key|p12|pfx)$/i.test(part)))
16
+ throw Error('WORKSPACE_PATH_FORBIDDEN');
17
+ return value;
18
+ }
19
+ const prefix = z.string().min(1).max(1024).refine(value => value === '.' ||
20
+ !value.startsWith('/') && !/[\\\x00-\x1f\x7f:*?]/.test(value) && value.split('/').every(p => p && p !== '.' && p !== '..'));
21
+ export const sourceScope = z.object({
22
+ appRoot: prefix,
23
+ include: z.array(prefix).min(1).max(100),
24
+ exclude: z.array(prefix).max(100),
25
+ }).strict();
26
+ export const defaultSourceScope = { appRoot: '.', include: ['.'], exclude: [] };
27
+ export function inSourceScope(path, scope) {
28
+ const contains = (p) => p === '.' || path === p || path.startsWith(p + '/');
29
+ return scope.include.some(contains) && !scope.exclude.some(contains);
30
+ }
31
+ export function sourceManifest(files) {
32
+ if (files.length > MAX_SOURCE_FILES)
33
+ throw Error('WORKSPACE_SOURCE_MANIFEST_INVALID');
34
+ const seen = new Set();
35
+ const manifest = files.map(({ path, digest: hash, mode }) => {
36
+ sourcePath(path);
37
+ // Reject ambiguous paths on case-insensitive/decomposing customer filesystems.
38
+ const key = path.normalize('NFC').toLowerCase();
39
+ if (seen.has(key) || !digest.safeParse(hash).success || ![0o644, 0o755].includes(mode))
40
+ throw Error('WORKSPACE_SOURCE_MANIFEST_INVALID');
41
+ seen.add(key);
42
+ return { path, digest: hash, mode };
43
+ }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
44
+ for (const key of seen) {
45
+ const parts = key.split('/');
46
+ for (let i = 1; i < parts.length; i++)
47
+ if (seen.has(parts.slice(0, i).join('/')))
48
+ throw Error('WORKSPACE_SOURCE_PATH_COLLISION');
49
+ }
50
+ return manifest;
51
+ }
52
+ export const sourceIdentity = (files) => JSON.stringify({ version: 1, files: sourceManifest(files) });
53
+ export function sourceChanges(before, after) {
54
+ const a = new Map(before.map(f => [f.path, f])), b = new Map(after.map(f => [f.path, f]));
55
+ return [...new Set([...a.keys(), ...b.keys()])].sort().flatMap(file => {
56
+ const old = a.get(file), next = b.get(file);
57
+ return old?.digest === next?.digest && old?.mode === next?.mode ? [] : [{
58
+ file, change: !old ? 'created' : !next ? 'deleted' : 'modified',
59
+ beforeDigest: old?.digest ?? null, digest: next?.digest ?? null,
60
+ beforeMode: old?.mode ?? null, mode: next?.mode ?? null,
61
+ }];
62
+ });
63
+ }
64
+ export const sourceOrigin = z.object({ commit: gitCommit }).strict();
65
+ export const snapshotProof = z.object({ revision: digest, archiveDigest: digest }).strict();
66
+ export const sourceChange = z.object({
67
+ format: z.literal(1), projectShortId: z.string().regex(/^[a-z0-9]{8}$/),
68
+ workspaceId: z.string().min(1).max(120), releaseId: z.string().min(1).max(120),
69
+ scope: sourceScope, origin: sourceOrigin.optional(), base: snapshotProof, result: snapshotProof,
70
+ }).strict();
71
+ /** Object field order can change through storage and JSON transports. */
72
+ export const sourceChangeIdentity = (change) => JSON.stringify(sourceChange.parse(change));
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ /** Repository-relative commands shared by local preparation and remote execution.
3
+ * Validation describes commands; it never executes or grants permission to them. */
4
+ export declare const executionPath: z.ZodString;
5
+ export declare const executionDirectory: z.ZodUnion<readonly [z.ZodLiteral<".">, z.ZodString]>;
6
+ export declare const workspaceRecipe: z.ZodObject<{
7
+ schemaCheck: z.ZodString;
8
+ schemaOutput: z.ZodOptional<z.ZodString>;
9
+ install: z.ZodArray<z.ZodObject<{
10
+ argv: z.ZodArray<z.ZodString>;
11
+ cwd: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<".">, z.ZodString]>>;
12
+ }, z.core.$strict>>;
13
+ checks: z.ZodArray<z.ZodObject<{
14
+ argv: z.ZodArray<z.ZodString>;
15
+ cwd: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<".">, z.ZodString]>>;
16
+ name: z.ZodString;
17
+ }, z.core.$strict>>;
18
+ preview: z.ZodObject<{
19
+ argv: z.ZodArray<z.ZodString>;
20
+ cwd: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<".">, z.ZodString]>>;
21
+ port: z.ZodNumber;
22
+ readyPath: z.ZodOptional<z.ZodString>;
23
+ }, z.core.$strict>;
24
+ allowedHosts: z.ZodArray<z.ZodString>;
25
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
26
+ }, z.core.$strict>;
27
+ export declare const sourceConfiguration: z.ZodObject<{
28
+ scope: z.ZodObject<{
29
+ appRoot: z.ZodString;
30
+ include: z.ZodArray<z.ZodString>;
31
+ exclude: z.ZodArray<z.ZodString>;
32
+ }, z.core.$strict>;
33
+ schemaVersion: z.ZodString;
34
+ recipe: z.ZodObject<{
35
+ schemaCheck: z.ZodString;
36
+ schemaOutput: z.ZodOptional<z.ZodString>;
37
+ install: z.ZodArray<z.ZodObject<{
38
+ argv: z.ZodArray<z.ZodString>;
39
+ cwd: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<".">, z.ZodString]>>;
40
+ }, z.core.$strict>>;
41
+ checks: z.ZodArray<z.ZodObject<{
42
+ argv: z.ZodArray<z.ZodString>;
43
+ cwd: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<".">, z.ZodString]>>;
44
+ name: z.ZodString;
45
+ }, z.core.$strict>>;
46
+ preview: z.ZodObject<{
47
+ argv: z.ZodArray<z.ZodString>;
48
+ cwd: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<".">, z.ZodString]>>;
49
+ port: z.ZodNumber;
50
+ readyPath: z.ZodOptional<z.ZodString>;
51
+ }, z.core.$strict>;
52
+ allowedHosts: z.ZodArray<z.ZodString>;
53
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
54
+ }, z.core.$strict>;
55
+ }, z.core.$strict>;
56
+ export type SourceConfiguration = z.infer<typeof sourceConfiguration>;
57
+ /** Same conservative preset for a local checkout and an immutable remote tree.
58
+ * Only explicit repository facts enter; this function never reads or runs code. */
59
+ export declare function nextSourceConfiguration(input: {
60
+ manifest: unknown;
61
+ files: string[];
62
+ schemaVersion: string;
63
+ }): SourceConfiguration;
package/dist/recipe.js ADDED
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod';
2
+ import { sourcePath, sourceScope } from './manifest.js';
3
+ /** Repository-relative commands shared by local preparation and remote execution.
4
+ * Validation describes commands; it never executes or grants permission to them. */
5
+ export const executionPath = z.string().refine((value) => {
6
+ try {
7
+ sourcePath(value);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }, 'Use a project-relative path; credentials and Git internals are excluded');
14
+ // The project root is a command directory, never a source-file address.
15
+ export const executionDirectory = z.union([z.literal('.'), executionPath]);
16
+ const command = z
17
+ .object({
18
+ argv: z.array(z.string().min(1).max(2048)).min(1).max(32),
19
+ cwd: executionDirectory.optional(),
20
+ })
21
+ .strict();
22
+ export const workspaceRecipe = z
23
+ .object({
24
+ schemaCheck: z.string().min(1).max(80),
25
+ // Compiler-owned output, never a model-supplied schema or command.
26
+ schemaOutput: executionPath.optional(),
27
+ install: z.array(command).max(8),
28
+ checks: z
29
+ .array(command.extend({ name: z.string().min(1).max(80) }))
30
+ .min(1)
31
+ .max(8),
32
+ preview: command.extend({
33
+ port: z.number().int().min(1024).max(65535),
34
+ readyPath: z.string().startsWith('/').max(500).optional(),
35
+ }),
36
+ allowedHosts: z.array(z.string().regex(/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/)).max(20),
37
+ env: z
38
+ .record(z.string().regex(/^(?:NEXT_PUBLIC_)?STORYSTICK_[A-Z0-9_]+$|^NODE_ENV$/), z.string().max(8192))
39
+ .optional(),
40
+ })
41
+ .strict();
42
+ export const sourceConfiguration = z
43
+ .object({
44
+ scope: sourceScope,
45
+ schemaVersion: z.string().min(1).max(128),
46
+ recipe: workspaceRecipe,
47
+ })
48
+ .strict()
49
+ .superRefine((value, ctx) => {
50
+ const names = value.recipe.checks.map((check) => check.name);
51
+ if (!names.includes(value.recipe.schemaCheck))
52
+ ctx.addIssue({
53
+ code: 'custom',
54
+ path: ['recipe', 'schemaCheck'],
55
+ message: 'Must name one of the declared checks',
56
+ });
57
+ if (new Set(names).size !== names.length)
58
+ ctx.addIssue({
59
+ code: 'custom',
60
+ path: ['recipe', 'checks'],
61
+ message: 'Check names must be unique',
62
+ });
63
+ if (!value.recipe.schemaOutput)
64
+ ctx.addIssue({
65
+ code: 'custom',
66
+ path: ['recipe', 'schemaOutput'],
67
+ message: 'Declare the compiler output used to verify release grammar',
68
+ });
69
+ });
70
+ /** Same conservative preset for a local checkout and an immutable remote tree.
71
+ * Only explicit repository facts enter; this function never reads or runs code. */
72
+ export function nextSourceConfiguration(input) {
73
+ const pkg = z.object({
74
+ workspaces: z.unknown().optional(),
75
+ dependencies: z.record(z.string(), z.string()).optional(),
76
+ devDependencies: z.record(z.string(), z.string()).optional(),
77
+ scripts: z.record(z.string(), z.string()).optional(),
78
+ }).parse(input.manifest);
79
+ if (pkg.workspaces !== undefined || input.files.includes('pnpm-workspace.yaml'))
80
+ throw Error('A monorepo needs an explicit source scope and command directories. Use a reviewed source configuration.');
81
+ if (!(pkg.dependencies?.next || pkg.devDependencies?.next) || !pkg.scripts?.dev || !pkg.scripts?.build)
82
+ throw Error('The Next.js preset needs next and the dev/build scripts. Use a reviewed source configuration for a different setup.');
83
+ if (!(pkg.devDependencies?.['@storystick/cli'] || pkg.dependencies?.['@storystick/cli']))
84
+ throw Error('Install @storystick/cli in this project before using the preset.');
85
+ const locks = ['pnpm-lock.yaml', 'package-lock.json'].filter(file => input.files.includes(file));
86
+ if (locks.length !== 1)
87
+ throw Error('The preset needs exactly one pnpm-lock.yaml or package-lock.json. Use a reviewed source configuration for a different package manager.');
88
+ const pnpm = locks[0] === 'pnpm-lock.yaml';
89
+ const run = (name) => [pnpm ? 'pnpm' : 'npm', 'run', name];
90
+ const cli = pnpm ? ['pnpm', 'exec', 'storystick'] : ['npx', '--no-install', 'storystick'];
91
+ const output = 'generated/storystick-schema.json';
92
+ return sourceConfiguration.parse({
93
+ scope: { appRoot: '.', include: ['.'], exclude: ['.storystick', '.vercel', '.next', 'node_modules', 'generated'] },
94
+ schemaVersion: input.schemaVersion,
95
+ recipe: {
96
+ install: [{ argv: pnpm ? ['pnpm', 'install', '--frozen-lockfile'] : ['npm', 'ci'] }, { argv: [...cli, 'generate'] }],
97
+ checks: [{ name: 'schema', argv: [...cli, 'schema', '--out', output] }, { name: 'build', argv: run('build') }],
98
+ schemaCheck: 'schema', schemaOutput: output,
99
+ preview: { argv: [...run('dev'), ...(pnpm ? [] : ['--']), '--hostname', '0.0.0.0', '--port', '3000'], port: 3000 },
100
+ allowedHosts: [],
101
+ },
102
+ });
103
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@storystick/source",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-rc.1",
4
+ "type": "module",
5
+ "license": "FSL-1.1-MIT",
6
+ "files": [
7
+ "dist",
8
+ "PUBLIC-API.md"
9
+ ],
10
+ "publishConfig": {
11
+ "access": "public",
12
+ "provenance": false,
13
+ "tag": "beta"
14
+ },
15
+ "exports": {
16
+ "./manifest": {
17
+ "types": "./dist/manifest.d.ts",
18
+ "import": "./dist/manifest.js"
19
+ },
20
+ "./archive": {
21
+ "types": "./dist/archive.d.ts",
22
+ "import": "./dist/archive.js"
23
+ },
24
+ "./recipe": {
25
+ "types": "./dist/recipe.d.ts",
26
+ "import": "./dist/recipe.js"
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "zod": "^4.6.7"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.13.10",
34
+ "typescript": "^5.8.2",
35
+ "vitest": "^3.1.1"
36
+ },
37
+ "engines": {
38
+ "node": ">=24.19.0 <25"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/withstorystick/storystick.git",
43
+ "directory": "packages/source"
44
+ },
45
+ "homepage": "https://storystick.app",
46
+ "bugs": {
47
+ "url": "https://storystick.app"
48
+ },
49
+ "scripts": {
50
+ "build": "node ../../scripts/package-dist.mjs clean && tsc",
51
+ "test": "vitest run"
52
+ }
6
53
  }