create-stacki 0.0.0-stage → 0.1.48
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 +21 -0
- package/README.md +48 -2
- package/dist/cli.mjs +559 -0
- package/dist/stacki-studio.json +1 -0
- package/package.json +31 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Timothy Ricks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-stacki
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Add Stacki Studio to an existing Astro project without installing the desktop app.
|
|
4
|
+
Requires Node.js 22.19.0 or newer and an Astro project using npm with a lockfile.
|
|
5
|
+
If the project has no npm lockfile yet, run `npm install` in it first.
|
|
6
|
+
|
|
7
|
+
From your project root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm create stacki@latest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or pass a project directory:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm create stacki@latest ./my-site
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The installer asks for confirmation, installs `/studio`, checks its types, and builds it before
|
|
20
|
+
activating it. It does not change your website source or root package manifest. Each npm release
|
|
21
|
+
contains a specific Studio source release; commit `/studio` to keep that version with your project.
|
|
22
|
+
Installation needs internet access to download Studio's locked npm dependencies.
|
|
23
|
+
|
|
24
|
+
Start the local editor from your project root:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm --prefix studio run dev
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Follow `studio/docs/cloudflare.md` for hosted editing. To get a prompt for your coding agent:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm create stacki@latest -- --agent
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For scripts, explicitly confirm installation with:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm create stacki@latest -- --yes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Existing Studio installations are detected and left untouched. Update through Studio's version
|
|
43
|
+
management when ready; updates replace editor customizations while retaining project configuration
|
|
44
|
+
and runtime state. Website source stays outside that replacement. Save customizations in Git first.
|
|
45
|
+
|
|
46
|
+
Installation uses `.stacki-studio/` for staging and locking. A failed validation keeps the staging
|
|
47
|
+
folder for diagnosis and does not activate Studio. It can be removed after checking the failure.
|
|
48
|
+
Cloudflare deployment and authentication are separate setup steps; this command does not create
|
|
49
|
+
cloud resources or deploy your site.
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// studio/scripts/createStacki/cli.ts
|
|
4
|
+
import path3 from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { stdin, stdout } from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
// studio/vendor/stacki/electron/project/studioInstall.ts
|
|
10
|
+
import { cp, lstat, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
// studio/vendor/stacki/shared/core/limits.ts
|
|
16
|
+
var LIMITS = {
|
|
17
|
+
/** Nodes in one page tree. A 50k-line page is already a code-view page. */
|
|
18
|
+
treeNodesMax: 2e4,
|
|
19
|
+
/** Nesting depth of one page tree. Hand-written markup rarely exceeds 20. */
|
|
20
|
+
treeDepthMax: 64,
|
|
21
|
+
/** Length of a tag or component name. */
|
|
22
|
+
tagNameCharsMax: 128,
|
|
23
|
+
/** Length of a single attribute name or value. */
|
|
24
|
+
attrCharsMax: 8192,
|
|
25
|
+
/** Attributes on one tag. */
|
|
26
|
+
attrsPerNodeMax: 256,
|
|
27
|
+
/** Text/comment/raw payload of one node, in UTF-16 code units. */
|
|
28
|
+
nodeValueCharsMax: 1e6,
|
|
29
|
+
/** Entries of each kind in a project scan (pages, layouts, components). */
|
|
30
|
+
scanEntriesMax: 1e4,
|
|
31
|
+
/** Folders listed by a project scan. */
|
|
32
|
+
scanFoldersMax: 2e3,
|
|
33
|
+
/** Props on one component schema. */
|
|
34
|
+
propSchemaFieldsMax: 512,
|
|
35
|
+
/** Literal options on one union-typed prop. */
|
|
36
|
+
propOptionsMax: 256,
|
|
37
|
+
/** Import declarations in one frontmatter block. */
|
|
38
|
+
importsMax: 256,
|
|
39
|
+
/** Length of one IPC payload string field. */
|
|
40
|
+
ipcFieldCharsMax: 1e7,
|
|
41
|
+
/** Length of one path crossing a boundary: Windows' extended-length limit. */
|
|
42
|
+
ipcPathCharsMax: 32768,
|
|
43
|
+
/** Items in one array or keys in one object crossing a boundary. */
|
|
44
|
+
ipcItemsMax: 1e5,
|
|
45
|
+
/** Nesting depth of one structured value crossing a boundary. */
|
|
46
|
+
ipcDepthMax: 128,
|
|
47
|
+
/** TextMate highlighting is synchronous CPU work after grammar startup. */
|
|
48
|
+
syntaxHighlightCharsMax: 1e6,
|
|
49
|
+
// Editor core (plan §8). The projection reuses treeNodesMax and treeDepthMax:
|
|
50
|
+
// a projected node is a page-tree node, so one name keeps one meaning.
|
|
51
|
+
/** Bytes of one source file. Merged up from electron/lib/mainLimits.ts so the
|
|
52
|
+
* renderer, the actor and the disk readers answer to one number. */
|
|
53
|
+
sourceBytesMax: 10 * 1024 * 1024,
|
|
54
|
+
/** Intents accepted by one document actor and not yet terminal. Past this,
|
|
55
|
+
* submission returns `backpressured`; the persistence layer holds the draft. */
|
|
56
|
+
intentsPendingMax: 64,
|
|
57
|
+
/** UTF-8 bytes of every string an intent carries, summed. A code patch or a
|
|
58
|
+
* `rewrite-text` may rewrite a whole file's content, so the bound is the file
|
|
59
|
+
* bound itself. */
|
|
60
|
+
intentPayloadBytesMax: 10 * 1024 * 1024,
|
|
61
|
+
/** Splices one intent may plan, and sites one multi-span operation may name.
|
|
62
|
+
* A loop rename in a generated page touches hundreds of sites, not millions. */
|
|
63
|
+
splicesPerIntentMax: 4096,
|
|
64
|
+
/** Work units one diff may spend, and each span mapping through it: one unit
|
|
65
|
+
* is one byte comparison or one frontier cell (shared/engine/diff.ts). A 10 MB file
|
|
66
|
+
* with a small edit costs about 4·10⁷ — both directions scan it once — so the
|
|
67
|
+
* bound admits the largest file and refuses quadratic blow-ups. Exhaustion is
|
|
68
|
+
* a `resource-limit` rejection, never a silent fallback. */
|
|
69
|
+
diffWorkMax: 5e7,
|
|
70
|
+
/** Edit distance (bytes deleted plus bytes inserted) one diff searches to.
|
|
71
|
+
* The frontiers it keeps grow with its square: 2·(D + 1)² 32-bit cells, about
|
|
72
|
+
* 34 MB at this bound. An external change larger than this, racing a pending
|
|
73
|
+
* intent, rejects with `resource-limit`; the renderer re-authors against the
|
|
74
|
+
* refreshed snapshot, where no diff is needed. */
|
|
75
|
+
diffDistanceMax: 2048,
|
|
76
|
+
/** Document actors one host keeps (electron/documents/documentActors.ts). An actor
|
|
77
|
+
* holds only its snapshot between intents, so the least recently used idle
|
|
78
|
+
* one is dropped past this bound and re-reads the disk on its next intent. */
|
|
79
|
+
documentActorsMax: 512,
|
|
80
|
+
/** Snapshot bytes all of one host's actors retain together. Without it, 512
|
|
81
|
+
* actors could each hold a 10 MB file; past it, idle actors are dropped. */
|
|
82
|
+
documentBytesRetainedMax: 64 * 1024 * 1024,
|
|
83
|
+
/** Parses one actor may have in flight: the current read plus one newer. */
|
|
84
|
+
parseTasksInFlightMax: 2,
|
|
85
|
+
/** Snapshots one actor retains: the committed one plus the candidate under
|
|
86
|
+
* verification. Pending intents keep only their authored preconditions. */
|
|
87
|
+
snapshotsRetainedMax: 2,
|
|
88
|
+
/** Earlier snapshots a host keeps per actor (step 6): the bytes the renderer
|
|
89
|
+
* authored an edit against, once the actor's own writes or an outside one
|
|
90
|
+
* replaced them. The renderer keeps authoring against the page it shows
|
|
91
|
+
* until a save lands with nothing newer typed, so a run of saves under a
|
|
92
|
+
* busy keyboard needs as many as the commit log holds. Past either bound,
|
|
93
|
+
* such an edit is refused, never guessed, and the page asks to be reloaded. */
|
|
94
|
+
authoredSnapshotsMax: 16,
|
|
95
|
+
/** Bytes of those earlier snapshots per actor, so sixteen copies of a 10 MB
|
|
96
|
+
* page never pile up; the oldest go first. */
|
|
97
|
+
authoredBytesRetainedMax: 16 * 1024 * 1024,
|
|
98
|
+
/** Commits a host remembers per actor — the bytes before, the bytes after,
|
|
99
|
+
* the splices between — so an edit authored before the actor's own recent
|
|
100
|
+
* writes is rebased exactly, without a diff (shared/engine/rebase.ts). */
|
|
101
|
+
commitLogEntriesMax: 16,
|
|
102
|
+
/** Undo entries one session keeps (the renderer's history; step 6 moves the
|
|
103
|
+
* bound here from a literal in App.tsx, where it was already 100). */
|
|
104
|
+
undoEntriesMax: 100,
|
|
105
|
+
/** Files one watcher tick may mark dirty. Events coalesce into a set, so the
|
|
106
|
+
* bound caps a burst like `git checkout`, not the event count. */
|
|
107
|
+
watcherFilesPerTickMax: 1024,
|
|
108
|
+
/** Source markers one preview render may carry — one per projected node. The
|
|
109
|
+
* frame counts the stamps of one render against it too (every rendered copy
|
|
110
|
+
* of a component stamps once), and the morph reloads past it (step 7). */
|
|
111
|
+
previewMarkersMax: 2e4,
|
|
112
|
+
/** Work one canvas patch may spend lining up the old and new renderings: one
|
|
113
|
+
* unit per cell of the child-list matrices it builds (electron/previewClient/morphClient.ts)
|
|
114
|
+
* — a list of 2 000 children changed in the middle is 4·10⁶ cells, 16 MB of
|
|
115
|
+
* Int32. Past it the canvas reloads, and says why, instead of freezing the
|
|
116
|
+
* editor on a page too big to diff (step 7). */
|
|
117
|
+
previewMorphWorkMax: 4e6,
|
|
118
|
+
/** Distinct files one preview render's manifest may name: the page, its
|
|
119
|
+
* layouts and every component that rendered (shared/page/previewToken.ts). A
|
|
120
|
+
* render past it has no token, and its events are refused. */
|
|
121
|
+
previewManifestFilesMax: 512,
|
|
122
|
+
/** UTF-16 units of one project-relative path in a preview stamp. */
|
|
123
|
+
previewStampPathCharsMax: 1024,
|
|
124
|
+
/** Diagnostics one parse-error projection carries, and the length of each. */
|
|
125
|
+
diagnosticsMax: 64,
|
|
126
|
+
diagnosticCharsMax: 4096
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// studio/vendor/stacki/shared/core/record.ts
|
|
130
|
+
function toRecord(input) {
|
|
131
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
132
|
+
return void 0;
|
|
133
|
+
}
|
|
134
|
+
return input;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// studio/vendor/stacki/shared/core/boundary.ts
|
|
138
|
+
var BOUNDARY_LIMITS = {
|
|
139
|
+
textLengthMax: LIMITS.ipcFieldCharsMax,
|
|
140
|
+
pathLengthMax: LIMITS.ipcPathCharsMax,
|
|
141
|
+
itemsMax: LIMITS.ipcItemsMax,
|
|
142
|
+
depthMax: LIMITS.ipcDepthMax
|
|
143
|
+
};
|
|
144
|
+
function record(input) {
|
|
145
|
+
const value = toRecord(input);
|
|
146
|
+
if (!value) {
|
|
147
|
+
throw new Error("Expected object");
|
|
148
|
+
}
|
|
149
|
+
if (Object.keys(value).length > BOUNDARY_LIMITS.itemsMax) {
|
|
150
|
+
throw new Error("Object exceeds limit");
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// studio/vendor/stacki/shared/ipc/studioValidation.ts
|
|
156
|
+
function string(value, maximum = 1e6) {
|
|
157
|
+
if (typeof value !== "string" || value.length > maximum) {
|
|
158
|
+
throw new Error("Invalid string");
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// studio/vendor/stacki/shared/ipc/studioRelease.ts
|
|
164
|
+
var RELEASE_BYTES_MAX = 32e6;
|
|
165
|
+
var RELEASE_FILES_MAX = 1500;
|
|
166
|
+
function releaseVersion(value) {
|
|
167
|
+
const version = string(value, 40);
|
|
168
|
+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
|
|
169
|
+
throw new Error("Expected an exact stable Stacki version");
|
|
170
|
+
}
|
|
171
|
+
return version;
|
|
172
|
+
}
|
|
173
|
+
function retainedPath(file) {
|
|
174
|
+
const first = file.split("/")[0] ?? "";
|
|
175
|
+
return [".studio", ".wrangler", "cloudflare", ".studio-state"].includes(first) || /^\.(env|dev\.vars)(\.|$)/.test(first);
|
|
176
|
+
}
|
|
177
|
+
function releasePath(value) {
|
|
178
|
+
const file = string(value, 500);
|
|
179
|
+
if (!/^[\w.-]+(?:\/[\w.-]+)*$/.test(file) || file.split("/").some((part) => part === "." || part === "..") || retainedPath(file) || !/^(src|scripts|vendor|docs|templates)\//.test(file) && ![
|
|
180
|
+
"package.json",
|
|
181
|
+
"package-lock.json",
|
|
182
|
+
"Dockerfile",
|
|
183
|
+
".dockerignore",
|
|
184
|
+
".gitignore",
|
|
185
|
+
"tsconfig.json",
|
|
186
|
+
"tsconfig.worker.json",
|
|
187
|
+
"README.md",
|
|
188
|
+
"AGENTS.md",
|
|
189
|
+
"LICENSE"
|
|
190
|
+
].includes(file)) {
|
|
191
|
+
throw new Error("Invalid replaceable Studio file: " + file);
|
|
192
|
+
}
|
|
193
|
+
return file;
|
|
194
|
+
}
|
|
195
|
+
function parseRelease(input) {
|
|
196
|
+
const value = record(input);
|
|
197
|
+
if (value["format"] !== 1 || !Array.isArray(value["files"]) || value["files"].length === 0 || value["files"].length > RELEASE_FILES_MAX) {
|
|
198
|
+
throw new Error("Unsupported Studio release");
|
|
199
|
+
}
|
|
200
|
+
const version = releaseVersion(value["version"]);
|
|
201
|
+
const names = /* @__PURE__ */ new Set();
|
|
202
|
+
let bytes = 0;
|
|
203
|
+
const files = value["files"].map((input2) => {
|
|
204
|
+
const file = record(input2);
|
|
205
|
+
const name = releasePath(file["path"]);
|
|
206
|
+
if (names.has(name.toLowerCase())) {
|
|
207
|
+
throw new Error("Duplicate Studio file");
|
|
208
|
+
}
|
|
209
|
+
names.add(name.toLowerCase());
|
|
210
|
+
const content = string(file["content"], 2e6);
|
|
211
|
+
bytes += new TextEncoder().encode(content).byteLength;
|
|
212
|
+
if (bytes > RELEASE_BYTES_MAX) {
|
|
213
|
+
throw new Error("Studio release is too large");
|
|
214
|
+
}
|
|
215
|
+
return { path: name, content };
|
|
216
|
+
});
|
|
217
|
+
for (const required of [
|
|
218
|
+
"package.json",
|
|
219
|
+
"package-lock.json",
|
|
220
|
+
"Dockerfile",
|
|
221
|
+
"scripts/build.ts",
|
|
222
|
+
"src/shared/release.json",
|
|
223
|
+
"vendor/stacki/LICENSE"
|
|
224
|
+
]) {
|
|
225
|
+
if (!names.has(required.toLowerCase())) {
|
|
226
|
+
throw new Error("Incomplete Studio release");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const file of files) {
|
|
230
|
+
if (file.path === "package.json" || file.path === "src/shared/release.json") {
|
|
231
|
+
if (record(JSON.parse(file.content))["version"] !== version) {
|
|
232
|
+
throw new Error("Studio release version mismatch");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { format: 1, version, files };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// studio/vendor/stacki/electron/project/studioInstall.ts
|
|
240
|
+
async function writeRelease(directory, release) {
|
|
241
|
+
for (const file of release.files) {
|
|
242
|
+
const destination = path.join(directory, file.path);
|
|
243
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
244
|
+
await writeFile(destination, file.content, { flag: "wx" });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function validateInstallation(directory, project) {
|
|
248
|
+
for (const args of [
|
|
249
|
+
["ci", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
250
|
+
["run", "check"],
|
|
251
|
+
["run", "build"]
|
|
252
|
+
]) {
|
|
253
|
+
await new Promise((resolve, reject) => {
|
|
254
|
+
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", args, {
|
|
255
|
+
cwd: directory,
|
|
256
|
+
stdio: "inherit",
|
|
257
|
+
shell: process.platform === "win32",
|
|
258
|
+
env: { ...process.env, STUDIO_PROJECT_ROOT: project }
|
|
259
|
+
});
|
|
260
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), 18e4);
|
|
261
|
+
child.once("error", (error) => {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
reject(error);
|
|
264
|
+
});
|
|
265
|
+
child.once("exit", (code) => {
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
if (code === 0) {
|
|
268
|
+
resolve();
|
|
269
|
+
} else {
|
|
270
|
+
reject(new Error("Studio validation failed; the existing installation was retained"));
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function installNewRelease(target, release, validate = validateInstallation) {
|
|
277
|
+
return installReleaseWithMode(target, release, { mode: "install" }, validate);
|
|
278
|
+
}
|
|
279
|
+
async function installReleaseWithMode(target, release, options, validate) {
|
|
280
|
+
const directory = path.resolve(target);
|
|
281
|
+
if (path.basename(directory) !== "studio") {
|
|
282
|
+
throw new Error("Updates target a project\u2019s studio directory");
|
|
283
|
+
}
|
|
284
|
+
const metadata = path.join(path.dirname(directory), ".stacki-studio");
|
|
285
|
+
await mkdir(metadata, { recursive: true });
|
|
286
|
+
if ((await lstat(metadata)).isSymbolicLink()) {
|
|
287
|
+
throw new Error("Update storage must not be a symlink");
|
|
288
|
+
}
|
|
289
|
+
await writeFile(path.join(metadata, ".gitignore"), "*\n");
|
|
290
|
+
const lock = await open(path.join(metadata, "update.lock"), "wx");
|
|
291
|
+
const identifier = `${release.version}-${randomUUID()}`;
|
|
292
|
+
const staging = path.join(metadata, "staging-" + identifier);
|
|
293
|
+
const backup = path.join(metadata, "backup-" + identifier);
|
|
294
|
+
try {
|
|
295
|
+
const previous = await lstat(directory).catch((error) => {
|
|
296
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
297
|
+
return void 0;
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
});
|
|
301
|
+
if (previous && options.mode === "install") {
|
|
302
|
+
throw new Error("Studio already exists; setup will not replace it");
|
|
303
|
+
}
|
|
304
|
+
if (previous && (!previous.isDirectory() || previous.isSymbolicLink())) {
|
|
305
|
+
throw new Error("Studio must be a real directory");
|
|
306
|
+
}
|
|
307
|
+
await mkdir(staging);
|
|
308
|
+
await writeRelease(staging, release);
|
|
309
|
+
if (!previous) {
|
|
310
|
+
await cp(path.join(staging, "templates/cloudflare"), path.join(staging, "cloudflare"), {
|
|
311
|
+
recursive: true
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
await validate(staging, path.dirname(directory));
|
|
315
|
+
if (previous) {
|
|
316
|
+
await activate(directory, staging, backup);
|
|
317
|
+
} else {
|
|
318
|
+
await rename(staging, directory);
|
|
319
|
+
}
|
|
320
|
+
return { version: release.version, backup: previous ? backup : void 0 };
|
|
321
|
+
} finally {
|
|
322
|
+
await lock.close();
|
|
323
|
+
await rm(path.join(metadata, "update.lock"), { force: true });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function activate(directory, staging, backup) {
|
|
327
|
+
const preserved = (await readdir(directory)).filter(retainedPath);
|
|
328
|
+
const moved = [];
|
|
329
|
+
await rename(directory, backup);
|
|
330
|
+
try {
|
|
331
|
+
for (const entry of preserved) {
|
|
332
|
+
await rename(path.join(backup, entry), path.join(staging, entry));
|
|
333
|
+
moved.push(entry);
|
|
334
|
+
}
|
|
335
|
+
await rename(staging, directory);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
for (const entry of moved.reverse()) {
|
|
338
|
+
await rename(path.join(staging, entry), path.join(backup, entry));
|
|
339
|
+
}
|
|
340
|
+
await rename(backup, directory);
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// studio/scripts/createStacki/options.ts
|
|
346
|
+
function parseOptions(args) {
|
|
347
|
+
if (args.length > 8 || args.some((arg) => arg.length > 4096)) {
|
|
348
|
+
throw new Error("Too many or oversized arguments");
|
|
349
|
+
}
|
|
350
|
+
let directory = ".";
|
|
351
|
+
let hasDirectory = false;
|
|
352
|
+
let yes = false;
|
|
353
|
+
let mode = "install";
|
|
354
|
+
for (const arg of args) {
|
|
355
|
+
if (arg === "--yes" || arg === "-y") {
|
|
356
|
+
yes = true;
|
|
357
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
358
|
+
mode = "help";
|
|
359
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
360
|
+
mode = "version";
|
|
361
|
+
} else if (arg === "--agent") {
|
|
362
|
+
mode = "agent";
|
|
363
|
+
} else if (arg.startsWith("-")) {
|
|
364
|
+
throw new Error("Unknown option: " + arg);
|
|
365
|
+
} else if (hasDirectory) {
|
|
366
|
+
throw new Error("Specify only one existing project directory");
|
|
367
|
+
} else {
|
|
368
|
+
directory = arg;
|
|
369
|
+
hasDirectory = true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { directory, yes, mode };
|
|
373
|
+
}
|
|
374
|
+
function requireSupportedNode(version) {
|
|
375
|
+
const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
376
|
+
const major = Number(parts?.[1]);
|
|
377
|
+
const minor = Number(parts?.[2]);
|
|
378
|
+
if (!parts || major < 22 || major === 22 && minor < 19) {
|
|
379
|
+
throw new Error("Stacki Studio needs Node.js 22.19.0 or newer");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// studio/scripts/createStacki/project.ts
|
|
384
|
+
import { lstat as lstat2, open as open2, realpath } from "node:fs/promises";
|
|
385
|
+
import path2 from "node:path";
|
|
386
|
+
|
|
387
|
+
// studio/src/shared/validation.ts
|
|
388
|
+
var MAX_SOURCE = 1e6;
|
|
389
|
+
function record2(value) {
|
|
390
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
391
|
+
throw new Error("Expected object");
|
|
392
|
+
}
|
|
393
|
+
return value;
|
|
394
|
+
}
|
|
395
|
+
function string2(value, maximum = MAX_SOURCE) {
|
|
396
|
+
if (typeof value !== "string" || value.length > maximum) {
|
|
397
|
+
throw new Error("Invalid string");
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// studio/scripts/createStacki/project.ts
|
|
403
|
+
async function readJson(file, maximum) {
|
|
404
|
+
const handle = await open2(file, "r");
|
|
405
|
+
try {
|
|
406
|
+
const stat = await handle.stat();
|
|
407
|
+
if (!stat.isFile() || stat.size > maximum) {
|
|
408
|
+
throw new Error("Expected a bounded JSON file: " + file);
|
|
409
|
+
}
|
|
410
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
411
|
+
} finally {
|
|
412
|
+
await handle.close();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async function projectDirectory(directory) {
|
|
416
|
+
const project = await realpath(path2.resolve(directory));
|
|
417
|
+
const manifest = record2(await readJson(path2.join(project, "package.json"), 1e6));
|
|
418
|
+
const dependencies = record2(manifest["dependencies"] ?? {});
|
|
419
|
+
const development = record2(manifest["devDependencies"] ?? {});
|
|
420
|
+
if (typeof dependencies["astro"] !== "string" && typeof development["astro"] !== "string") {
|
|
421
|
+
throw new Error("Run this from an existing Astro project, or pass its directory");
|
|
422
|
+
}
|
|
423
|
+
await requireProjectLock(project);
|
|
424
|
+
return project;
|
|
425
|
+
}
|
|
426
|
+
async function requireProjectLock(project) {
|
|
427
|
+
for (const name of ["npm-shrinkwrap.json", "package-lock.json"]) {
|
|
428
|
+
const file = path2.join(project, name);
|
|
429
|
+
const exists = await lstat2(file).catch((error) => {
|
|
430
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
431
|
+
return void 0;
|
|
432
|
+
}
|
|
433
|
+
throw error;
|
|
434
|
+
});
|
|
435
|
+
if (!exists) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const value = record2(await readJson(file, 8e6));
|
|
439
|
+
const version = value["lockfileVersion"];
|
|
440
|
+
if (typeof version === "number" && Number.isSafeInteger(version) && version >= 1) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
throw new Error("Invalid npm lockfile. Run npm install in your project before adding Studio");
|
|
444
|
+
}
|
|
445
|
+
throw new Error("Studio needs an npm lockfile. Run npm install in your project, then retry");
|
|
446
|
+
}
|
|
447
|
+
async function installedVersion(project) {
|
|
448
|
+
const target = path2.join(project, "studio");
|
|
449
|
+
const stat = await lstat2(target).catch((error) => {
|
|
450
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
451
|
+
return void 0;
|
|
452
|
+
}
|
|
453
|
+
throw error;
|
|
454
|
+
});
|
|
455
|
+
if (!stat) {
|
|
456
|
+
return void 0;
|
|
457
|
+
}
|
|
458
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
459
|
+
throw new Error("The studio path already exists and cannot be replaced by setup");
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const manifest = record2(await readJson(path2.join(target, "package.json"), 1e6));
|
|
463
|
+
if (string2(manifest["name"], 100) !== "@stacki/studio") {
|
|
464
|
+
throw new Error("Not Stacki Studio");
|
|
465
|
+
}
|
|
466
|
+
const release = record2(await readJson(path2.join(target, "src/shared/release.json"), 4096));
|
|
467
|
+
return releaseVersion(release["version"]);
|
|
468
|
+
} catch {
|
|
469
|
+
throw new Error("A studio folder already exists. Setup will not overwrite its contents");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// studio/scripts/createStacki/cli.ts
|
|
474
|
+
var HELP = `Add Stacki Studio to an existing Astro project.
|
|
475
|
+
|
|
476
|
+
npm create stacki@latest [project-directory]
|
|
477
|
+
npm create stacki@latest -- --yes
|
|
478
|
+
|
|
479
|
+
Options:
|
|
480
|
+
--yes, -y Install without the confirmation prompt
|
|
481
|
+
--agent Print a setup prompt for your coding agent; change no files
|
|
482
|
+
--help, -h Show this help
|
|
483
|
+
--version, -v Show the bundled Studio version
|
|
484
|
+
|
|
485
|
+
Studio is installed in ./studio. Existing installations are never replaced.
|
|
486
|
+
`;
|
|
487
|
+
var AGENT = `Set up Stacki Studio in this existing Astro project.
|
|
488
|
+
If /studio is missing, run npm create stacki@latest -- --yes from the project root.
|
|
489
|
+
If it exists, inspect it first; do not overwrite customizations or update silently.
|
|
490
|
+
Read studio/AGENTS.md, studio/README.md, and studio/docs/cloudflare.md.
|
|
491
|
+
Start the local preview with npm --prefix studio run dev and verify /studio.
|
|
492
|
+
Guide me through GitHub authentication and Cloudflare Containers deployment using
|
|
493
|
+
studio/cloudflare and the documented setup. Ask me for project-specific choices.
|
|
494
|
+
Keep credentials in local secret storage or Cloudflare secrets, never source files.
|
|
495
|
+
Preserve website source. Verify local editing before any deployment.
|
|
496
|
+
Ask for approval before creating paid resources or deploying anything.
|
|
497
|
+
Report external setup that still needs my action. Updates are always opt-in.`;
|
|
498
|
+
async function confirm(project) {
|
|
499
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
"Use --yes for a noninteractive installation, or --agent for setup instructions"
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const input = createInterface({ input: stdin, output: stdout });
|
|
505
|
+
try {
|
|
506
|
+
const answer = await input.question(
|
|
507
|
+
`Install Stacki Studio in ${path3.join(project, "studio")}? [y/N] `
|
|
508
|
+
);
|
|
509
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
510
|
+
} finally {
|
|
511
|
+
input.close();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function main() {
|
|
515
|
+
const options = parseOptions(process.argv.slice(2));
|
|
516
|
+
if (options.mode === "help" || options.mode === "agent") {
|
|
517
|
+
console.log(options.mode === "help" ? HELP : AGENT);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
requireSupportedNode(process.versions.node);
|
|
521
|
+
const release = parseRelease(
|
|
522
|
+
await readJson(
|
|
523
|
+
fileURLToPath(new URL("./stacki-studio.json", import.meta.url)),
|
|
524
|
+
RELEASE_BYTES_MAX
|
|
525
|
+
)
|
|
526
|
+
);
|
|
527
|
+
if (options.mode === "version") {
|
|
528
|
+
console.log(release.version);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const project = await projectDirectory(options.directory);
|
|
532
|
+
const installed = await installedVersion(project);
|
|
533
|
+
if (installed) {
|
|
534
|
+
console.log(`Stacki Studio ${installed} is already installed. No files were changed.`);
|
|
535
|
+
console.log(
|
|
536
|
+
"Open Studio to manage its version. Run npm --prefix studio run dev in this project."
|
|
537
|
+
);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
console.log(`Stacki Studio ${release.version} \u2014 1/3 Install`);
|
|
541
|
+
if (!options.yes && !await confirm(project)) {
|
|
542
|
+
console.log("Cancelled. No files were changed.");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
console.log("Installing dependencies, checking types, and building Studio\u2026");
|
|
546
|
+
await installNewRelease(path3.join(project, "studio"), release);
|
|
547
|
+
console.log(`
|
|
548
|
+
Installed Stacki Studio ${release.version} in ${path3.join(project, "studio")}`);
|
|
549
|
+
console.log("\n2/3 Local preview\nFrom your project root, run: npm --prefix studio run dev");
|
|
550
|
+
console.log(
|
|
551
|
+
"\n3/3 Go live\nFollow studio/docs/cloudflare.md to configure GitHub and Cloudflare."
|
|
552
|
+
);
|
|
553
|
+
console.log("For a prompt to give your agent: npm create stacki@latest -- --agent");
|
|
554
|
+
console.log("Commit /studio to keep this version with your project. Updates are opt-in.");
|
|
555
|
+
}
|
|
556
|
+
void main().catch((error) => {
|
|
557
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
558
|
+
process.exitCode = 1;
|
|
559
|
+
});
|