@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.
Files changed (123) hide show
  1. package/CONTENT-IMPORT.md +349 -0
  2. package/LICENSE +112 -0
  3. package/PUBLIC-API.md +30 -0
  4. package/README.md +263 -2
  5. package/assets/next-v1/app/[[...pathname]]/error.tsx +10 -0
  6. package/assets/next-v1/app/[[...pathname]]/not-found.tsx +8 -0
  7. package/assets/next-v1/app/[[...pathname]]/page.tsx +38 -0
  8. package/assets/next-v1/app/api/storystick/[...action]/route.ts +4 -0
  9. package/assets/next-v1/app/layout.tsx +28 -0
  10. package/assets/next-v1/app/sitemap.ts +3 -0
  11. package/assets/next-v1/env.local.example +13 -0
  12. package/assets/next-v1/globals.css +17 -0
  13. package/assets/next-v1/lib/site.ts +10 -0
  14. package/assets/next-v1/next.config.ts +4 -0
  15. package/assets/skills/storystick-development/SKILL.md +79 -0
  16. package/assets/skills/storystick-development/references/setup.md +152 -0
  17. package/assets/skills/storystick-development/references/story-and-next.md +49 -0
  18. package/dist/_backfill-recommendation.d.ts +12 -0
  19. package/dist/_backfill-recommendation.js +18 -0
  20. package/dist/asset-import.d.ts +50 -0
  21. package/dist/asset-import.js +160 -0
  22. package/dist/assistant-read-client.d.ts +3 -0
  23. package/dist/assistant-read-client.js +8 -0
  24. package/dist/auth-device.d.ts +153 -0
  25. package/dist/auth-device.js +390 -0
  26. package/dist/bootstrap-client.d.ts +52 -0
  27. package/dist/bootstrap-client.js +19 -0
  28. package/dist/cli-error.d.ts +54 -0
  29. package/dist/cli-error.js +107 -0
  30. package/dist/cli.d.ts +68 -0
  31. package/dist/cli.js +1520 -0
  32. package/dist/commands/archive.d.ts +17 -0
  33. package/dist/commands/archive.js +86 -0
  34. package/dist/commands/backfill-refs.d.ts +54 -0
  35. package/dist/commands/backfill-refs.js +217 -0
  36. package/dist/commands/connect.d.ts +11 -0
  37. package/dist/commands/connect.js +124 -0
  38. package/dist/commands/connection.d.ts +57 -0
  39. package/dist/commands/connection.js +83 -0
  40. package/dist/commands/content.d.ts +11 -0
  41. package/dist/commands/content.js +107 -0
  42. package/dist/commands/delivery.d.ts +22 -0
  43. package/dist/commands/delivery.js +99 -0
  44. package/dist/commands/info.d.ts +35 -0
  45. package/dist/commands/info.js +62 -0
  46. package/dist/commands/list.d.ts +28 -0
  47. package/dist/commands/list.js +116 -0
  48. package/dist/commands/migrate.d.ts +80 -0
  49. package/dist/commands/migrate.js +550 -0
  50. package/dist/commands/release.d.ts +8 -0
  51. package/dist/commands/release.js +60 -0
  52. package/dist/commands/setup.d.ts +6 -0
  53. package/dist/commands/setup.js +58 -0
  54. package/dist/commands/slice-previews.d.ts +17 -0
  55. package/dist/commands/slice-previews.js +233 -0
  56. package/dist/commands/source.d.ts +82 -0
  57. package/dist/commands/source.js +192 -0
  58. package/dist/content-import.d.ts +78 -0
  59. package/dist/content-import.js +210 -0
  60. package/dist/generated-transaction.d.ts +22 -0
  61. package/dist/generated-transaction.js +330 -0
  62. package/dist/hub-client.d.ts +72 -0
  63. package/dist/hub-client.js +178 -0
  64. package/dist/init-operation.d.ts +98 -0
  65. package/dist/init-operation.js +427 -0
  66. package/dist/init-readiness.d.ts +10 -0
  67. package/dist/init-readiness.js +50 -0
  68. package/dist/init-scaffold.d.ts +54 -0
  69. package/dist/init-scaffold.js +439 -0
  70. package/dist/init.d.ts +51 -0
  71. package/dist/init.js +789 -0
  72. package/dist/mcp-execution.d.ts +15 -0
  73. package/dist/mcp-execution.js +90 -0
  74. package/dist/mcp.d.ts +7 -0
  75. package/dist/mcp.js +41 -0
  76. package/dist/migration-artifact.d.ts +13 -0
  77. package/dist/migration-artifact.js +154 -0
  78. package/dist/operations-client.d.ts +137 -0
  79. package/dist/operations-client.js +130 -0
  80. package/dist/output.d.ts +347 -0
  81. package/dist/output.js +771 -0
  82. package/dist/project-access.d.ts +18 -0
  83. package/dist/project-access.js +1 -0
  84. package/dist/project-connection.d.ts +62 -0
  85. package/dist/project-connection.js +347 -0
  86. package/dist/project-execution-client.d.ts +2 -0
  87. package/dist/project-execution-client.js +33 -0
  88. package/dist/push-client.d.ts +100 -0
  89. package/dist/push-client.js +160 -0
  90. package/dist/release-context.d.ts +13 -0
  91. package/dist/release-context.js +39 -0
  92. package/dist/release-schema-client.d.ts +16 -0
  93. package/dist/release-schema-client.js +33 -0
  94. package/dist/release-sync.d.ts +36 -0
  95. package/dist/release-sync.js +31 -0
  96. package/dist/resolve.d.ts +15 -0
  97. package/dist/resolve.js +80 -0
  98. package/dist/runtime-config.d.ts +18 -0
  99. package/dist/runtime-config.js +86 -0
  100. package/dist/schema-export.d.ts +10 -0
  101. package/dist/schema-export.js +28 -0
  102. package/dist/setup.d.ts +65 -0
  103. package/dist/setup.js +135 -0
  104. package/dist/source-git.d.ts +36 -0
  105. package/dist/source-git.js +197 -0
  106. package/dist/starter-resolver.d.ts +82 -0
  107. package/dist/starter-resolver.js +495 -0
  108. package/dist/sync-transaction.d.ts +29 -0
  109. package/dist/sync-transaction.js +159 -0
  110. package/dist/transport.d.ts +8 -0
  111. package/dist/transport.js +73 -0
  112. package/examples/sanity/README.md +79 -0
  113. package/examples/sanity/convert.mjs +228 -0
  114. package/examples/sanity/fixture/data.ndjson +7 -0
  115. package/examples/sanity/fixture/mapping.json +44 -0
  116. package/examples/sanity/fixture/originals/mark.svg +1 -0
  117. package/examples/sanity/stories/articleBody.story +23 -0
  118. package/examples/sanity/stories/copy.story +7 -0
  119. package/examples/sanity/stories/page.story +19 -0
  120. package/examples/sanity/stories/picture.story +7 -0
  121. package/examples/sanity/stories/settings.story +9 -0
  122. package/examples/sanity/stories/stack.story +10 -0
  123. package/package.json +75 -3
@@ -0,0 +1,210 @@
1
+ /** Node management client. Never imported by customer rendering bundles. */
2
+ import { createReadStream } from 'node:fs';
3
+ import { readFile, writeFile, mkdir, link, unlink, rename, stat, realpath } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { StorystickError } from './cli-error.js';
8
+ import { CONTENT_IMPORT_LIMITS, importDigest, importObject, importAssetDependencies, parseImportManifest, planImportBatches } from '@storystick/core/content-import';
9
+ import { callHubApi } from './hub-client.js';
10
+ import { readAssetImport, prepareAssetImport } from './asset-import.js';
11
+ export async function readContentImport(file) {
12
+ const path = resolve(file), directory = await realpath(dirname(path));
13
+ const input = importObject(JSON.parse(await readFile(path, 'utf8')), 'manifest');
14
+ const { records: files, assets: assetFiles, ...metadata } = input;
15
+ if (!Array.isArray(files) || !files.length || files.some(file => typeof file !== 'string'))
16
+ throw new Error('Manifest records must list local NDJSON files.');
17
+ const records = [];
18
+ let totalBytes = 0;
19
+ for (const file of files) {
20
+ const resolved = resolve(directory, file), rel = relative(directory, resolved);
21
+ if (isAbsolute(file) || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
22
+ throw new Error('NDJSON files must be inside the manifest directory.');
23
+ const actual = relative(directory, await realpath(resolved));
24
+ if (actual === '..' || actual.startsWith('../') || isAbsolute(actual))
25
+ throw new Error('NDJSON files must be inside the manifest directory.');
26
+ const declaredBytes = (await stat(resolved)).size;
27
+ const previousBytes = totalBytes;
28
+ totalBytes += declaredBytes;
29
+ if (totalBytes > 32 * 1024 * 1024)
30
+ throw new Error('Import exceeds the 32 MiB input limit.');
31
+ // NDJSON separates records with LF, not Unicode prose separators. Node's
32
+ // readline can split U+2028/U+2029 inside otherwise valid JSON strings.
33
+ const stream = createReadStream(resolved, { encoding: 'utf8' });
34
+ let pending = '', lineNumber = 0, actualBytes = 0;
35
+ const parseLine = (line) => {
36
+ lineNumber++;
37
+ if (!line.trim())
38
+ return;
39
+ try {
40
+ records.push(JSON.parse(line));
41
+ }
42
+ catch {
43
+ throw new Error(`${file}:${lineNumber}: invalid JSON`);
44
+ }
45
+ if (records.length > CONTENT_IMPORT_LIMITS.records)
46
+ throw new Error('Import exceeds the 2000 document limit.');
47
+ };
48
+ try {
49
+ for await (const chunk of stream) {
50
+ actualBytes += Buffer.byteLength(chunk);
51
+ if (previousBytes + actualBytes > CONTENT_IMPORT_LIMITS.totalBytes)
52
+ throw new Error('Import exceeds the 32 MiB input limit.');
53
+ pending += chunk;
54
+ let end;
55
+ while ((end = pending.indexOf('\n')) >= 0) {
56
+ parseLine(pending.slice(0, end));
57
+ pending = pending.slice(end + 1);
58
+ }
59
+ }
60
+ if (pending)
61
+ parseLine(pending);
62
+ totalBytes = previousBytes + actualBytes;
63
+ }
64
+ finally {
65
+ stream.destroy();
66
+ }
67
+ }
68
+ const batches = planImportBatches(records);
69
+ const manifest = parseImportManifest({ ...metadata, batchDigests: await Promise.all(batches.map(importDigest)), recordCount: records.length });
70
+ const assets = [];
71
+ if (assetFiles !== undefined) {
72
+ if (!Array.isArray(assetFiles) || assetFiles.length > CONTENT_IMPORT_LIMITS.records || assetFiles.some(f => typeof f !== 'string'))
73
+ throw new Error('Manifest assets must list local asset manifests.');
74
+ const keys = new Set();
75
+ for (const name of assetFiles) {
76
+ const actual = await realpath(resolve(directory, name)), rel = relative(directory, actual);
77
+ if (isAbsolute(name) || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
78
+ throw new Error('Asset manifests must be inside the manifest directory.');
79
+ const asset = await readAssetImport(actual);
80
+ if (asset.input.namespace !== manifest.namespace || keys.has(asset.input.key))
81
+ throw new Error('Asset source keys must be unique and use the corpus namespace.');
82
+ keys.add(asset.input.key);
83
+ assets.push(asset);
84
+ }
85
+ for (const record of batches.flat())
86
+ for (const key of importAssetDependencies(record)) {
87
+ if (!keys.has(key))
88
+ throw new Error(`${record.key}: missing asset manifest for ${key}.`);
89
+ }
90
+ }
91
+ return { manifest, batches, assets };
92
+ }
93
+ /** Reserve source identities without uploading. All local inputs have already been checked. */
94
+ export async function prepareContentImportAssets(transport, source) {
95
+ const plans = [];
96
+ for (const asset of source.assets) {
97
+ const plan = await prepareAssetImport(transport, asset.input);
98
+ if (!plan.assetId || plan.inputDigest !== await importDigest(asset.input))
99
+ throw new Error('Asset plan does not match its local source.');
100
+ plans.push({ ...asset, plan });
101
+ }
102
+ const manifest = plans.length ? parseImportManifest({ ...source.manifest,
103
+ assets: plans.map(({ input, plan }) => ({ key: input.key, assetId: plan.assetId, inputDigest: plan.inputDigest })),
104
+ }) : source.manifest;
105
+ return { manifest, batches: source.batches, assets: plans };
106
+ }
107
+ export function contentImportTransport(projectShortId, options) {
108
+ return managementImportTransport('/api/cli/content-import', projectShortId, options);
109
+ }
110
+ export function assetImportTransport(projectShortId, options) {
111
+ return managementImportTransport('/api/cli/asset-import', projectShortId, options);
112
+ }
113
+ export function uploadBudgetTransport(projectShortId, options) {
114
+ return managementImportTransport('/api/cli/upload-budget', projectShortId, options);
115
+ }
116
+ function managementImportTransport(endpoint, projectShortId, options) {
117
+ return async (request) => {
118
+ for (let attempt = 0;; attempt++) {
119
+ try {
120
+ return await callHubApi(endpoint, { ...options, method: 'POST', body: { projectShortId, request } });
121
+ }
122
+ catch (error) {
123
+ const transient = error instanceof StorystickError
124
+ ? error.retryable === true || [429, 502, 503, 504].includes(error.status ?? 0)
125
+ : error instanceof TypeError;
126
+ const wait = error instanceof StorystickError && error.retryAfterMs !== undefined ? error.retryAfterMs : 500 * 2 ** attempt;
127
+ if (!transient || attempt >= 4 || wait > 60_000 || options.signal?.aborted)
128
+ throw error;
129
+ // Every request has a server-owned replay receipt. Never mint a new
130
+ // operation identity because a response was lost or throttled.
131
+ await delay(wait, undefined, { signal: options.signal });
132
+ }
133
+ }
134
+ };
135
+ }
136
+ /** Receipt identity is written before the first remote call. No content/secrets. */
137
+ export async function importRunIdentity(input) {
138
+ if (input.resume)
139
+ return input.resume;
140
+ const digest = await importDigest({ project: input.project, gateway: input.gateway, manifest: input.manifest });
141
+ const directory = resolve(input.root, '.storystick', 'imports'), path = resolve(directory, `${digest}.json`);
142
+ if (!input.newRun)
143
+ try {
144
+ const receipt = JSON.parse(await readFile(path, 'utf8'));
145
+ if (receipt.digest !== digest || typeof receipt.runId !== 'string')
146
+ throw new Error('Invalid local import receipt.');
147
+ return receipt.runId;
148
+ }
149
+ catch (error) {
150
+ if (error.code !== 'ENOENT')
151
+ throw error;
152
+ }
153
+ await mkdir(directory, { recursive: true });
154
+ const runId = randomUUID(), temporary = `${path}.${runId}.tmp`;
155
+ await writeFile(temporary, JSON.stringify({ version: 1, runId, digest, project: input.project }, null, 2) + '\n', { mode: 0o600 });
156
+ if (input.newRun) {
157
+ await rename(temporary, path);
158
+ return runId;
159
+ }
160
+ try {
161
+ // Atomic no-replace installation: simultaneous clients share one run.
162
+ await link(temporary, path);
163
+ return runId;
164
+ }
165
+ catch (error) {
166
+ if (error.code !== 'EEXIST')
167
+ throw error;
168
+ return importRunIdentity(input);
169
+ }
170
+ finally {
171
+ await unlink(temporary);
172
+ }
173
+ }
174
+ export async function prepareContentImport(transport, input) {
175
+ const { runId, manifest, batches } = input;
176
+ await transport({ operation: 'begin', runId, manifest });
177
+ for (let index = 0; index < batches.length; index++)
178
+ await transport({ operation: 'stage', runId, index, records: batches[index] });
179
+ const plans = [];
180
+ for (let index = 0; index < batches.length; index++)
181
+ plans.push(await transport({ operation: 'plan', runId, index }));
182
+ const status = await transport({ operation: 'seal', runId });
183
+ return { ...status, plans };
184
+ }
185
+ export async function applyContentImport(transport, runId, planDigest) {
186
+ const state = await transport({ operation: 'status', runId });
187
+ if (state.planDigest !== planDigest)
188
+ throw new Error('The supplied digest does not match the sealed server plan.');
189
+ if (state.state === 'cancelled' || state.state === 'preparing' || state.state === 'expired')
190
+ throw new Error(`Import cannot apply while ${state.state}.`);
191
+ for (let offset = state.assetsReady; offset < state.assetCount; offset += CONTENT_IMPORT_LIMITS.recordsPerBatch) {
192
+ await transport({ operation: 'ack-assets', runId, index: Math.floor(offset / CONTENT_IMPORT_LIMITS.recordsPerBatch), planDigest });
193
+ }
194
+ for (let index = state.applied; index < state.batches; index++)
195
+ await transport({ operation: 'apply', runId, index, planDigest });
196
+ return transport({ operation: 'status', runId });
197
+ }
198
+ export async function verifyContentImport(transport, runId) {
199
+ const state = await transport({ operation: 'status', runId });
200
+ const assets = [];
201
+ for (let offset = 0; offset < state.assetsReady; offset += CONTENT_IMPORT_LIMITS.recordsPerBatch) {
202
+ const proof = await transport({ operation: 'verify-assets', runId, index: Math.floor(offset / CONTENT_IMPORT_LIMITS.recordsPerBatch) });
203
+ assets.push(...proof.assets);
204
+ }
205
+ const batches = [];
206
+ for (let index = 0; index < state.applied; index++)
207
+ batches.push(await transport({ operation: 'verify', runId, index }));
208
+ return { ...state, verified: state.state === 'complete' && state.assetsReady === state.assetCount && assets.every(a => a.matches) && batches.every(b => [...b.documents, ...b.assets].every(d => d.matches)),
209
+ projected: state.state === 'complete' && batches.every(b => b.projection === 'caught-up'), verification: batches, assetVerification: assets };
210
+ }
@@ -0,0 +1,22 @@
1
+ import type { GeneratedFile } from '@storystick/core';
2
+ export type GenerationFaultPoint = 'after-generation-write' | 'before-pointer-commit' | 'after-pointer-commit';
3
+ export interface GeneratedTransactionOptions {
4
+ readonly check?: boolean;
5
+ /** Test-only deterministic crash seam. */
6
+ readonly faultAt?: GenerationFaultPoint;
7
+ /** Test-only child-process pause seam used to prove hard-crash recovery. */
8
+ readonly pauseAt?: GenerationFaultPoint;
9
+ readonly onPause?: (point: GenerationFaultPoint) => void;
10
+ }
11
+ export interface GeneratedTransactionResult {
12
+ readonly files: readonly string[];
13
+ readonly generationId: string;
14
+ }
15
+ export interface PreparedGeneratedTransaction extends GeneratedTransactionResult {
16
+ readonly outputDirectory: string;
17
+ }
18
+ export declare function commitGeneratedFiles(outputDirectory: string, generated: readonly GeneratedFile[], options?: GeneratedTransactionOptions): Promise<GeneratedTransactionResult>;
19
+ /** Write immutable bytes without installing facades or moving the pointer. */
20
+ export declare function prepareGeneratedFiles(outputDirectory: string, generated: readonly GeneratedFile[], options?: Pick<GeneratedTransactionOptions, 'faultAt' | 'pauseAt' | 'onPause'>): Promise<PreparedGeneratedTransaction>;
21
+ /** Commit an already staged immutable generation, including after restart. */
22
+ export declare function commitPreparedGeneration(outputDirectory: string, generationId: string, options?: Pick<GeneratedTransactionOptions, 'faultAt' | 'pauseAt' | 'onPause'>): Promise<GeneratedTransactionResult>;
@@ -0,0 +1,330 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readFile, readdir, rename, rm, } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ const GENERATIONS_DIRECTORY = '.storystick-generations';
5
+ const CURRENT_POINTER = '.storystick-current.ts';
6
+ const GENERATION_MANIFEST = '.storystick-generation.json';
7
+ const STABLE_FILES = ['types.ts', 'query.ts', 'preview-provenance.ts'];
8
+ const GENERATED_FILE_MARKER = '// Auto-generated by Storystick. Do not edit.';
9
+ const LEGACY_GENERATED_PREFIXES = [
10
+ `// ──────────────────────────────────────────\n${GENERATED_FILE_MARKER}\n`,
11
+ `// -----------------------------------------------------------------------------\n${GENERATED_FILE_MARKER}\n`,
12
+ ];
13
+ const FACADE = [
14
+ GENERATED_FILE_MARKER,
15
+ '// A single atomic pointer selects the complete immutable generation.',
16
+ "export * from './.storystick-current'",
17
+ '',
18
+ ].join('\n');
19
+ export async function commitGeneratedFiles(outputDirectory, generated, options = {}) {
20
+ const files = normalizedFiles(generated);
21
+ const generationId = generationIdFor(files);
22
+ const expected = expectedGeneration(outputDirectory, generationId, files);
23
+ if (options.check) {
24
+ const drift = await checkGeneration(expected);
25
+ if (drift.length > 0) {
26
+ throw new Error(`Generated artifacts are out of date:\n${drift.map((entry) => `- ${entry}`).join('\n')}`);
27
+ }
28
+ return { files: STABLE_FILES.map((file) => join(outputDirectory, file)), generationId };
29
+ }
30
+ await prepareGeneratedFiles(outputDirectory, files, options);
31
+ return commitPreparedGeneration(outputDirectory, generationId, options);
32
+ }
33
+ /** Write immutable bytes without installing facades or moving the pointer. */
34
+ export async function prepareGeneratedFiles(outputDirectory, generated, options = {}) {
35
+ const files = normalizedFiles(generated);
36
+ const generationId = generationIdFor(files);
37
+ const expected = expectedGeneration(outputDirectory, generationId, files);
38
+ await prepareOutputDirectory(outputDirectory);
39
+ await validateOwnedPaths(outputDirectory);
40
+ await writeImmutableGeneration(expected);
41
+ await checkpoint(options, 'after-generation-write');
42
+ return {
43
+ outputDirectory,
44
+ generationId,
45
+ files: STABLE_FILES.map((file) => join(outputDirectory, file)),
46
+ };
47
+ }
48
+ /** Commit an already staged immutable generation, including after restart. */
49
+ export async function commitPreparedGeneration(outputDirectory, generationId, options = {}) {
50
+ if (!/^g-[a-f0-9]{24}$/u.test(generationId)) {
51
+ throw new Error('Invalid prepared generation id.');
52
+ }
53
+ await prepareOutputDirectory(outputDirectory);
54
+ await validateOwnedPaths(outputDirectory);
55
+ const files = await readPreparedGeneration(outputDirectory, generationId);
56
+ const expected = expectedGeneration(outputDirectory, generationId, files);
57
+ await installStableFacades(outputDirectory);
58
+ await checkpoint(options, 'before-pointer-commit');
59
+ await atomicWriteFile(join(outputDirectory, CURRENT_POINTER), expected.pointer);
60
+ await syncDirectory(outputDirectory);
61
+ await checkpoint(options, 'after-pointer-commit');
62
+ await cleanupOwnedOrphans(outputDirectory, generationId);
63
+ return { files: STABLE_FILES.map((file) => join(outputDirectory, file)), generationId };
64
+ }
65
+ function normalizedFiles(generated) {
66
+ const byName = new Map();
67
+ for (const file of generated) {
68
+ if (!STABLE_FILES.includes(file.filename)) {
69
+ throw new Error(`Generator does not own ${JSON.stringify(file.filename)}.`);
70
+ }
71
+ if (basename(file.filename) !== file.filename || byName.has(file.filename)) {
72
+ throw new Error(`Invalid generated filename ${JSON.stringify(file.filename)}.`);
73
+ }
74
+ byName.set(file.filename, { filename: file.filename, content: file.content });
75
+ }
76
+ for (const required of STABLE_FILES) {
77
+ if (!byName.has(required))
78
+ throw new Error(`Missing generated file ${required}.`);
79
+ }
80
+ return [...byName.values()].sort((left, right) => left.filename.localeCompare(right.filename));
81
+ }
82
+ function generationIdFor(files) {
83
+ const hash = createHash('sha256');
84
+ for (const file of files) {
85
+ hash.update(file.filename);
86
+ hash.update('\0');
87
+ hash.update(file.content);
88
+ hash.update('\0');
89
+ }
90
+ return `g-${hash.digest('hex').slice(0, 24)}`;
91
+ }
92
+ function expectedGeneration(outputDirectory, generationId, files) {
93
+ const relativeRoot = `./${GENERATIONS_DIRECTORY}/${generationId}`;
94
+ const pointer = [
95
+ '// Auto-generated by Storystick. Do not edit.',
96
+ `// Generation: ${generationId}`,
97
+ ...files.map((file) => `export * from '${relativeRoot}/${file.filename.replace(/\.ts$/u, '')}'`),
98
+ '',
99
+ ].join('\n');
100
+ const manifest = `${JSON.stringify({
101
+ version: 1,
102
+ generationId,
103
+ files: files.map((file) => ({
104
+ name: file.filename,
105
+ sha256: createHash('sha256').update(file.content).digest('hex'),
106
+ })),
107
+ }, null, 2)}\n`;
108
+ const generationDirectory = join(outputDirectory, GENERATIONS_DIRECTORY, generationId);
109
+ return { outputDirectory, generationId, generationDirectory, files, pointer, manifest };
110
+ }
111
+ async function checkGeneration(expected) {
112
+ const drift = [];
113
+ for (const stable of STABLE_FILES) {
114
+ if (await readRegularFile(join(expected.outputDirectory, stable)) !== FACADE)
115
+ drift.push(stable);
116
+ }
117
+ if (await readRegularFile(join(expected.outputDirectory, CURRENT_POINTER)) !== expected.pointer) {
118
+ drift.push(CURRENT_POINTER);
119
+ }
120
+ for (const file of expected.files) {
121
+ if (await readRegularFile(join(expected.generationDirectory, file.filename)) !== file.content) {
122
+ drift.push(`${GENERATIONS_DIRECTORY}/${expected.generationId}/${file.filename}`);
123
+ }
124
+ }
125
+ if (await readRegularFile(join(expected.generationDirectory, GENERATION_MANIFEST)) !== expected.manifest) {
126
+ drift.push(`${GENERATIONS_DIRECTORY}/${expected.generationId}/${GENERATION_MANIFEST}`);
127
+ }
128
+ return drift;
129
+ }
130
+ async function prepareOutputDirectory(outputDirectory) {
131
+ const status = await safeLstat(outputDirectory);
132
+ if (status?.isSymbolicLink())
133
+ throw new Error('Generated output directory must not be a symlink.');
134
+ if (status && !status.isDirectory())
135
+ throw new Error('Generated output path must be a directory.');
136
+ await mkdir(outputDirectory, { recursive: true });
137
+ }
138
+ async function validateOwnedPaths(outputDirectory) {
139
+ for (const name of [...STABLE_FILES, CURRENT_POINTER, GENERATIONS_DIRECTORY]) {
140
+ const path = join(outputDirectory, name);
141
+ const status = await safeLstat(path);
142
+ if (!status)
143
+ continue;
144
+ if (status.isSymbolicLink())
145
+ throw new Error(`Generated owned path ${name} must not be a symlink.`);
146
+ if (name === GENERATIONS_DIRECTORY) {
147
+ if (!status.isDirectory())
148
+ throw new Error(`${name} must be a directory.`);
149
+ continue;
150
+ }
151
+ if (!status.isFile())
152
+ throw new Error(`Generated owned path ${name} must be a file.`);
153
+ const content = await readFile(path, 'utf8');
154
+ if (name === CURRENT_POINTER && !content.startsWith('// Auto-generated by Storystick.')) {
155
+ throw new Error(`Refusing collision on generated owned file ${name}.`);
156
+ }
157
+ if (STABLE_FILES.includes(name) &&
158
+ !isOwnedGeneratedFile(content)) {
159
+ throw new Error(`Refusing collision on generated owned file ${name}.`);
160
+ }
161
+ }
162
+ }
163
+ function isOwnedGeneratedFile(content) {
164
+ return content === FACADE ||
165
+ content.startsWith(GENERATED_FILE_MARKER) ||
166
+ LEGACY_GENERATED_PREFIXES.some((prefix) => content.startsWith(prefix));
167
+ }
168
+ async function writeImmutableGeneration(expected) {
169
+ await mkdir(join(expected.outputDirectory, GENERATIONS_DIRECTORY), { recursive: true });
170
+ const existing = await safeLstat(expected.generationDirectory);
171
+ if (existing) {
172
+ if (!existing.isDirectory() || (await checkGenerationFiles(expected)).length > 0) {
173
+ throw new Error(`Immutable generation collision at ${expected.generationId}.`);
174
+ }
175
+ return;
176
+ }
177
+ const temporary = `${expected.generationDirectory}.tmp-${randomUUID()}`;
178
+ await mkdir(temporary);
179
+ try {
180
+ for (const file of expected.files)
181
+ await writeSyncedFile(join(temporary, file.filename), file.content, true);
182
+ await writeSyncedFile(join(temporary, GENERATION_MANIFEST), expected.manifest, true);
183
+ await syncDirectory(temporary);
184
+ await rename(temporary, expected.generationDirectory);
185
+ await syncDirectory(join(expected.outputDirectory, GENERATIONS_DIRECTORY));
186
+ }
187
+ catch (error) {
188
+ await rm(temporary, { recursive: true, force: true });
189
+ throw error;
190
+ }
191
+ }
192
+ async function checkGenerationFiles(expected) {
193
+ const drift = [];
194
+ for (const file of expected.files) {
195
+ if (await readRegularFile(join(expected.generationDirectory, file.filename)) !== file.content)
196
+ drift.push(file.filename);
197
+ }
198
+ if (await readRegularFile(join(expected.generationDirectory, GENERATION_MANIFEST)) !== expected.manifest) {
199
+ drift.push(GENERATION_MANIFEST);
200
+ }
201
+ return drift;
202
+ }
203
+ async function readPreparedGeneration(outputDirectory, generationId) {
204
+ const directory = join(outputDirectory, GENERATIONS_DIRECTORY, generationId);
205
+ const rawManifest = await readRegularFile(join(directory, GENERATION_MANIFEST));
206
+ if (!rawManifest)
207
+ throw new Error(`Prepared generation ${generationId} is missing.`);
208
+ let manifest;
209
+ try {
210
+ manifest = JSON.parse(rawManifest);
211
+ }
212
+ catch {
213
+ throw new Error(`Prepared generation ${generationId} manifest is invalid.`);
214
+ }
215
+ if (typeof manifest !== 'object' ||
216
+ manifest === null ||
217
+ manifest.version !== 1 ||
218
+ manifest.generationId !== generationId ||
219
+ !Array.isArray(manifest.files)) {
220
+ throw new Error(`Prepared generation ${generationId} manifest is invalid.`);
221
+ }
222
+ const declared = manifest.files;
223
+ if (declared.length !== STABLE_FILES.length ||
224
+ declared.some((file) => typeof file.name !== 'string' ||
225
+ !STABLE_FILES.includes(file.name) ||
226
+ typeof file.sha256 !== 'string')) {
227
+ throw new Error(`Prepared generation ${generationId} manifest is invalid.`);
228
+ }
229
+ const files = [];
230
+ for (const item of declared) {
231
+ const content = await readRegularFile(join(directory, item.name));
232
+ if (content === null ||
233
+ createHash('sha256').update(content).digest('hex') !== item.sha256) {
234
+ throw new Error(`Prepared generation ${generationId} failed integrity check.`);
235
+ }
236
+ files.push({ filename: item.name, content });
237
+ }
238
+ const normalized = normalizedFiles(files);
239
+ if (generationIdFor(normalized) !== generationId) {
240
+ throw new Error(`Prepared generation ${generationId} identity mismatch.`);
241
+ }
242
+ return normalized;
243
+ }
244
+ async function installStableFacades(outputDirectory) {
245
+ for (const stable of STABLE_FILES) {
246
+ const path = join(outputDirectory, stable);
247
+ if (await readRegularFile(path) !== FACADE)
248
+ await atomicWriteFile(path, FACADE);
249
+ }
250
+ }
251
+ async function atomicWriteFile(path, content) {
252
+ const temporary = `${path}.tmp-${randomUUID()}`;
253
+ await writeSyncedFile(temporary, content, true);
254
+ try {
255
+ await rename(temporary, path);
256
+ }
257
+ catch (error) {
258
+ await rm(temporary, { force: true });
259
+ throw error;
260
+ }
261
+ }
262
+ async function writeSyncedFile(path, content, exclusive) {
263
+ const handle = await open(path, exclusive ? 'wx' : 'w');
264
+ try {
265
+ await handle.writeFile(content, 'utf8');
266
+ await handle.sync();
267
+ }
268
+ finally {
269
+ await handle.close();
270
+ }
271
+ }
272
+ async function cleanupOwnedOrphans(outputDirectory, current) {
273
+ const root = join(outputDirectory, GENERATIONS_DIRECTORY);
274
+ for (const entry of await readdir(root, { withFileTypes: true })) {
275
+ if (!entry.isDirectory() || entry.name === current || !/^g-[a-f0-9]{24}$/u.test(entry.name))
276
+ continue;
277
+ const directory = join(root, entry.name);
278
+ const manifest = await readRegularFile(join(directory, GENERATION_MANIFEST));
279
+ if (!manifest)
280
+ continue;
281
+ try {
282
+ const parsed = JSON.parse(manifest);
283
+ if (parsed.version === 1 && parsed.generationId === entry.name) {
284
+ await rm(directory, { recursive: true });
285
+ }
286
+ }
287
+ catch {
288
+ // Unknown directories are deliberately preserved.
289
+ }
290
+ }
291
+ }
292
+ async function syncDirectory(path) {
293
+ const handle = await open(path, 'r');
294
+ try {
295
+ await handle.sync();
296
+ }
297
+ finally {
298
+ await handle.close();
299
+ }
300
+ }
301
+ async function readRegularFile(path) {
302
+ const status = await safeLstat(path);
303
+ if (!status || !status.isFile() || status.isSymbolicLink())
304
+ return null;
305
+ return readFile(path, 'utf8');
306
+ }
307
+ async function safeLstat(path) {
308
+ try {
309
+ return await lstat(path);
310
+ }
311
+ catch (error) {
312
+ if (isNotFound(error))
313
+ return null;
314
+ throw error;
315
+ }
316
+ }
317
+ function isNotFound(error) {
318
+ return typeof error === 'object' && error !== null &&
319
+ 'code' in error && error.code === 'ENOENT';
320
+ }
321
+ async function checkpoint(options, point) {
322
+ if (options.faultAt === point)
323
+ throw new Error(`Injected generation fault at ${point}.`);
324
+ if (options.pauseAt === point) {
325
+ options.onPause?.(point);
326
+ await new Promise(() => {
327
+ setInterval(() => undefined, 1_000);
328
+ });
329
+ }
330
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Hub API client (SPEC-1 Item #3 — session-expired UX).
3
+ *
4
+ * Every CLI command that calls a user-scoped Hub endpoint MUST route through
5
+ * `callHubApi`. Two things it guarantees:
6
+ *
7
+ * 1. **Lazy 401 recovery.** If the CLI JWT has expired, the Hub returns
8
+ * 401. Instead of dumping a stack trace and exiting, we print a single
9
+ * friendly line, launch device-auth login inline, and retry the original
10
+ * request ONCE. Second 401 on retry propagates — we do not loop.
11
+ *
12
+ * 2. **Structured error surface.** The Hub's Pattern D error envelope is
13
+ * `{ code, message }`; on non-2xx responses we throw a `StorystickError`
14
+ * whose message ends with the Hub's message, tacked with `(code)` when
15
+ * present. Command-level code prints these directly without extra
16
+ * wrapping.
17
+ *
18
+ * Edge cases (per spec §Edge cases):
19
+ *
20
+ * - 403 Forbidden → do NOT retry. Treated as "valid token, insufficient
21
+ * rights" (e.g. user removed from project mid-session, endpoint requires
22
+ * admin). Surface verbatim.
23
+ * - 429 on retry → do NOT retry again. The first call already burned the
24
+ * per-IP bucket; a second retry would just slam it harder.
25
+ * - User cancels re-auth (Ctrl-C, closes browser, polls expire) →
26
+ * `deviceAuthLogin` throws; we repackage as `StorystickError('Login
27
+ * cancelled.')` so commands can catch + exit 1 without a stack trace.
28
+ *
29
+ * This module deliberately takes NO dependency on commander/cac/pc — it is
30
+ * pure transport. The CLI command layer owns all user-facing printing.
31
+ */
32
+ import { type LoginOptions } from './auth-device.js';
33
+ export interface HubApiOptions {
34
+ /** Binary source downloads use the same signed session and error handling. */
35
+ responseType?: 'json' | 'bytes';
36
+ /** HTTP method. Defaults to GET. */
37
+ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
38
+ /** JSON body (will be stringified). Presence flips Content-Type. */
39
+ body?: unknown;
40
+ /** Retained authority for a multi-request content operation. */
41
+ contentShapeCapability?: string;
42
+ /** Gateway base URL (no trailing slash). Tests pass a mock server. */
43
+ gatewayUrl?: string;
44
+ /** Injected `fetch`, for tests. */
45
+ fetchImpl?: typeof fetch;
46
+ /** Overrides for the inline device-auth flow when we retry. */
47
+ login?: LoginOptions;
48
+ /** Hook called once with the verification URL + userCode when a retry
49
+ * triggers device-auth login. The CLI uses this to print the "visit this
50
+ * URL / type this code" banner. */
51
+ onAuthPrompt?: (data: {
52
+ issuer: string;
53
+ verificationUrl: string;
54
+ userCode: string;
55
+ expiresIn: number;
56
+ }) => void;
57
+ /** Optional AbortSignal wiring (propagates into device-auth polling). */
58
+ signal?: AbortSignal;
59
+ /** Deadline applied to each Hub request. */
60
+ timeoutMs?: number;
61
+ /** Root used for `.env.local` runtime URL overrides. */
62
+ rootDir?: string;
63
+ /** Explicit test/embedding override for whether a device flow may start. */
64
+ interactiveAuth?: boolean;
65
+ }
66
+ /**
67
+ * Call a Hub user-scoped endpoint with automatic retry-on-401.
68
+ *
69
+ * Success path: returns the parsed JSON body.
70
+ * Failure path: throws `StorystickError` with a clean, user-facing message.
71
+ */
72
+ export declare function callHubApi<T>(path: string, options?: HubApiOptions): Promise<T>;