create-objectstack 17.0.0 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,165 @@
1
1
  # create-objectstack
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1eb28a1: Retire the five remote content templates from the scaffolder's catalog.
8
+
9
+ `todo`, `compliance`, `content`, `contracts` and `procurement` were delisted
10
+ from the official ObjectStack template marketplace and are no longer
11
+ maintained, but the CLI carried its own hardcoded catalog and never learned
12
+ that: `--help` recommended all five by name with marketing descriptions, and
13
+ the `Available:` line on a bad `-t` offered them too.
14
+
15
+ - `blank` (bundled, offline) is now the whole catalog, so the help text
16
+ advertises only what is actually supported.
17
+ - Asking for one of the five by name — `-t todo` in an old script or tutorial —
18
+ is refused with a message that says the template was retired, instead of the
19
+ generic "Unknown template" error that reads as a typo.
20
+ - The GitHub tarball-fetch path that served the remote templates is removed
21
+ along with its `tar` dependency; nothing else reached it.
22
+
23
+ Note this corrects the catalog at HEAD only. Already-published versions keep
24
+ advertising the retired templates until a new version of `create-objectstack`
25
+ is released.
26
+
27
+ ### Patch Changes
28
+
29
+ - 4906c90: Fix scaffolded projects describing themselves as the blank template (#9263)
30
+
31
+ `rewriteProjectIdentity` rewrote `id` / `namespace` / `name` in both
32
+ `objectstack.config.ts` and `objectstack.manifest.json` from the project name,
33
+ but left `description` untouched — every scaffolded project carried the blank
34
+ template's own line verbatim ("Minimal ObjectStack environment — a clean
35
+ slate for building."), confidently wrong rather than empty, and printed by
36
+ the first command the getting-started flow tells people to run (`os
37
+ validate`).
38
+
39
+ The scaffolder now drops `description` from both files instead of rewriting
40
+ it. There is nothing but the project name to derive a replacement from, and a
41
+ name-derived sentence (e.g. "Support Desk — an ObjectStack environment.")
42
+ would be a bare restatement of the `name`/`displayName` row already shown —
43
+ worse than no sentence at all. `os validate` already omits the description
44
+ line entirely when the field is unset, so a freshly scaffolded project now
45
+ prints cleanly:
46
+
47
+ ```
48
+ Support Desk v0.1.0
49
+ ```
50
+
51
+ instead of
52
+
53
+ ```
54
+ Support Desk v0.1.0
55
+ Minimal ObjectStack environment — a clean slate for building.
56
+ ```
57
+ - f2f09e4: fix(create-objectstack): the scaffolded Dockerfile pins the runtime image to the CLI that builds the artifact, instead of `latest` under a comment saying to pin (#9017)
58
+
59
+ `src/templates/blank/Dockerfile` shipped `FROM ghcr.io/objectstack-ai/objectstack:latest`
60
+ directly beneath a comment instructing the reader to "pin the tag to the
61
+ `@objectstack/cli` version in your package.json so the runtime matches the CLI that built
62
+ the artifact" — an instruction the scaffold itself did not follow. Every app made with
63
+ `npx create-objectstack` shipped that contradiction from day one, and `docker/README.md`'s
64
+ tag table already scopes `latest` to quick starts while documenting `X.Y.Z` as the
65
+ production pin.
66
+
67
+ Measured on scaffolded output rather than the template's bytes, before the fix:
68
+
69
+ ```
70
+ emitted package.json cli range : ^17.0.0
71
+ emitted Dockerfile FROM : FROM ghcr.io/objectstack-ai/objectstack:latest
72
+ agreement (tag vs cli range) : DISAGREE
73
+ ```
74
+
75
+ **The tag is resolved after `install`, from the installed CLI — not from the generated
76
+ `package.json`.** That file carries a caret RANGE, and the two are not interchangeable:
77
+ npm resolves `^17.0.0` to the newest 17.x, so pinning the range's floor would ship a
78
+ runtime image *older* than the CLI that built the artifact — breaking the same promise in
79
+ a new way. The rolling `:17` tag does match the range's float window but is exactly what
80
+ the tag table tells production not to use. The resolved version is the only value that
81
+ makes the sentence true, and it is the rule the repo already applies for this purpose in
82
+ `.github/workflows/scaffold-e2e.yml` ("Pin the runtime's CLI to the SAME version the
83
+ generated project actually resolved to — NOT a hardcoded `latest`").
84
+
85
+ **Both halves move together.** Pinning the line while leaving an imperative to pin by hand
86
+ would relocate the contradiction rather than remove it, so the comment above the `FROM`
87
+ line is replaced in the same rewrite. With `--skip-install` there is no resolved version:
88
+ the tag stays `latest` and the comment keeps telling the reader to pin — which is true on
89
+ that path, because there the user really must do it by hand.
90
+
91
+ The regression proof asserts on **scaffolded output**, never on the template: it scaffolds
92
+ with the real copy/sync/pin path, plants an installed CLI whose version is deliberately
93
+ *not* the range's floor (the normal case, and the one that a package.json-derived tag
94
+ would get wrong), and checks the emitted `FROM` tag against the emitted `package.json`
95
+ range with a satisfies-check rather than equality.
96
+
97
+ `.github/workflows/scaffold-e2e.yml` now reads the tag it builds its local runtime image
98
+ under **out of the generated Dockerfile** instead of hardcoding `:latest`. Those were two
99
+ hand-matched literals; had they skewed, Docker would have quietly pulled the last
100
+ published image instead of the one built from this checkout, and the job's own stated
101
+ hermeticity would have been false while it stayed green.
102
+ - 0a5adba: fix(create-objectstack): the blank template's `specVersion` stops shipping eleven majors stale, and the version-time sync covers every declared surface on every template (#9264)
103
+
104
+ The one bundled template declared the platform it targets in **two** places that
105
+ disagreed by eleven majors:
106
+
107
+ | file | key | was |
108
+ |:--|:--|:--|
109
+ | `objectstack.manifest.json` | `specVersion` | `^6.0.0` |
110
+ | `objectstack.config.ts` | `engines.protocol` | `^17` |
111
+
112
+ `scripts/sync-template-versions.mjs` re-stamped the config key and the template's
113
+ `@objectstack/*` dependency ranges, and **never opened the manifest at all**. So
114
+ `engines.protocol` tracked every major bump while `specVersion` sat at the value
115
+ it held when the script was written — and a green `sync-template-versions` run
116
+ was never evidence about it, because the script's failure mode was loud for the
117
+ keys it covered and mute for the key it did not.
118
+
119
+ **This is not confined to the registry contract.** `create-objectstack` copies
120
+ the manifest into every scaffolded project, rewriting `name`, `displayName` and
121
+ `namespace` and dropping `description` — it has never touched `specVersion`. So
122
+ every project scaffolded since v7 was stamped with a `^6.0.0` spec range while
123
+ installing `@objectstack/spec@^17.0.0`.
124
+
125
+ **The two keys are two facts, and the fix keeps them apart.** `engines.protocol`
126
+ is the ADR-0087 D1 runtime handshake range and carries the protocol major
127
+ (`^17`). `specVersion` is documented by `TemplateManifestSchema` as the
128
+ "Compatible `@objectstack/spec` semver range" and carries the package range
129
+ (`^17.0.0`) — the same value the script already writes into the template's own
130
+ `@objectstack/spec` dependency, so the manifest and the `package.json` now state
131
+ one fact once. They agree on the major only because the spec package's major and
132
+ the protocol major are kept in lockstep; they are stamped from two different
133
+ values.
134
+
135
+ Deleting the key was not available: `specVersion` is **required** by
136
+ `TemplateManifestSchema`, and every shipped manifest is parsed against it by
137
+ `check:template-manifests`.
138
+
139
+ **Two structural changes, because one-key-one-file coverage is what let this
140
+ sit:**
141
+
142
+ - the sync script's file list is now **discovered**, not hard-coded — templates
143
+ are found by walking `src/templates/`, the same way `check-template-manifests`
144
+ finds the manifests it parses, so a second template is covered on the day it
145
+ lands;
146
+ - **every stamp is required**. A template whose file is missing, whose stamp is
147
+ absent, or whose `package.json` declares no `@objectstack/*` dependency is a
148
+ hard failure naming the path — never a skip. A skipped stamp is
149
+ indistinguishable from a synced one in the log, which is the invisibility this
150
+ fixes.
151
+
152
+ The manifest is rewritten as **text** rather than parsed and re-serialized:
153
+ `objectstack.manifest.json` keeps `scaffold.variables` compact on one line, and
154
+ `JSON.stringify(…, null, 2)` would reformat unrelated structure on every release.
155
+
156
+ CI coverage lands as four per-template ratchets in `template-consistency.test.ts`,
157
+ generalized off `blank` onto the same directory walk — including the invariant
158
+ that catches this exact class: the manifest's `specVersion` must equal the
159
+ `@objectstack/spec` range the template actually installs. Either file alone can
160
+ be self-consistently stale; only comparing them catches a stamp that covered one
161
+ and not the other.
162
+
3
163
  ## 17.0.0
4
164
 
5
165
  ### Major Changes
package/README.md CHANGED
@@ -27,16 +27,14 @@ npx create-objectstack my-app --skip-install
27
27
  | Template | Source | Description |
28
28
  | --- | --- | --- |
29
29
  | `blank` *(default)* | bundled (offline) | Minimal starter — one object, REST API, ready to extend |
30
- | `todo` | remote | Universal task & project management starter |
31
- | `compliance` | remote | Compliance posture & evidence management (SOC2 / ISO27001) |
32
- | `content` | remote | Content marketing pipeline — editorial calendar & channel ROI |
33
- | `contracts` | remote | Post-signature CLM — approvals, obligations, renewals |
34
- | `procurement` | remote | Source-to-pay vendors, POs, receipts, invoice matching |
35
-
36
- Remote templates are fetched from
37
- [`objectstack-ai/templates`](https://github.com/objectstack-ai/templates) at
38
- scaffold time and require network access; `blank` is bundled and always works
39
- offline.
30
+
31
+ `blank` is bundled inside the npm package, so scaffolding never needs network
32
+ access.
33
+
34
+ The remote content templates (`todo`, `compliance`, `content`, `contracts`,
35
+ `procurement`) have been **retired** — they were delisted from the ObjectStack
36
+ template marketplace and are no longer maintained. Asking for one by name tells
37
+ you so rather than failing as an unknown template.
40
38
 
41
39
  ## Options
42
40
 
package/dist/index.js CHANGED
@@ -1,16 +1,10 @@
1
1
  // src/index.ts
2
2
  import { Command } from "commander";
3
3
  import chalk from "chalk";
4
- import fs3 from "fs";
5
- import path3 from "path";
6
- import os from "os";
4
+ import fs4 from "fs";
5
+ import path4 from "path";
7
6
  import { execSync } from "child_process";
8
7
  import { fileURLToPath } from "url";
9
- import { pipeline } from "stream/promises";
10
- import { createGunzip } from "zlib";
11
- import { createWriteStream, createReadStream } from "fs";
12
- import { mkdtemp, rm } from "fs/promises";
13
- import * as tar from "tar";
14
8
 
15
9
  // src/pkg-utils.ts
16
10
  function syncObjectStackDeps(pkg, version) {
@@ -114,39 +108,94 @@ function findStaleNamespacePrefixes(dir, oldNs) {
114
108
  return stale;
115
109
  }
116
110
 
117
- // src/index.ts
118
- var __filename2 = fileURLToPath(import.meta.url);
119
- var __dirname2 = path3.dirname(__filename2);
120
- var BUNDLED_TEMPLATES_DIR = path3.resolve(__dirname2, "templates");
121
- var REMOTE_REPO = "objectstack-ai/templates";
122
- var REMOTE_BRANCH = "main";
123
- var REMOTE_TARBALL_URL = `https://codeload.github.com/${REMOTE_REPO}/tar.gz/refs/heads/${REMOTE_BRANCH}`;
111
+ // src/template-registry.ts
124
112
  var TEMPLATES = {
125
113
  blank: {
126
114
  description: "Minimal starter \u2014 one object, REST API, ready to extend",
127
115
  source: { kind: "bundled", dir: "blank" }
128
- },
129
- todo: {
130
- description: "Universal task & project management starter",
131
- source: { kind: "remote", pkg: "todo" }
132
- },
133
- compliance: {
134
- description: "Compliance posture & evidence management (SOC2 / ISO27001)",
135
- source: { kind: "remote", pkg: "compliance" }
136
- },
137
- content: {
138
- description: "Content marketing pipeline \u2014 editorial calendar & channel ROI",
139
- source: { kind: "remote", pkg: "content" }
140
- },
141
- contracts: {
142
- description: "Post-signature CLM \u2014 approvals, obligations, renewals",
143
- source: { kind: "remote", pkg: "contracts" }
144
- },
145
- procurement: {
146
- description: "Source-to-pay \u2014 vendors, POs, receipts, invoice matching",
147
- source: { kind: "remote", pkg: "procurement" }
148
116
  }
149
117
  };
118
+ var RETIRED_TEMPLATES = [
119
+ "todo",
120
+ "compliance",
121
+ "content",
122
+ "contracts",
123
+ "procurement"
124
+ ];
125
+ function lookupTemplate(name) {
126
+ const template = TEMPLATES[name];
127
+ if (template) return { kind: "found", name, template };
128
+ if (RETIRED_TEMPLATES.includes(name)) return { kind: "retired", name };
129
+ return { kind: "unknown", name };
130
+ }
131
+ function templateNames() {
132
+ return Object.keys(TEMPLATES);
133
+ }
134
+
135
+ // src/runtime-image.ts
136
+ import fs3 from "fs";
137
+ import path3 from "path";
138
+ var RUNTIME_FROM_RE = /^FROM ghcr\.io\/objectstack-ai\/objectstack:([A-Za-z0-9_][A-Za-z0-9_.+-]*)[ \t]*$/;
139
+ var PINNABLE_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
140
+ var PROSE_COMMENT_RE = /^#[ \t]+\S/;
141
+ function readResolvedCliVersion(targetDir) {
142
+ const pkgPath = path3.join(
143
+ targetDir,
144
+ "node_modules",
145
+ "@objectstack",
146
+ "cli",
147
+ "package.json"
148
+ );
149
+ try {
150
+ const version = JSON.parse(fs3.readFileSync(pkgPath, "utf8")).version;
151
+ return typeof version === "string" && PINNABLE_VERSION_RE.test(version) ? version : void 0;
152
+ } catch {
153
+ return void 0;
154
+ }
155
+ }
156
+ function pinnedComment(version) {
157
+ return [
158
+ "# Pinned at scaffold time to the @objectstack/cli version this project",
159
+ "# resolved, so the runtime runs the same CLI that built your artifact.",
160
+ "# Move both together when you upgrade \u2014 see docker/README.md tag table.",
161
+ `FROM ghcr.io/objectstack-ai/objectstack:${version}`
162
+ ];
163
+ }
164
+ function pinRuntimeImage(targetDir, version) {
165
+ if (!PINNABLE_VERSION_RE.test(version)) {
166
+ return { pinned: false, reason: `'${version}' is not a pinnable version` };
167
+ }
168
+ const dockerfile = path3.join(targetDir, "Dockerfile");
169
+ let text;
170
+ try {
171
+ text = fs3.readFileSync(dockerfile, "utf8");
172
+ } catch {
173
+ return { pinned: false, reason: "no Dockerfile in the scaffolded project" };
174
+ }
175
+ const lines = text.split("\n");
176
+ const fromIdx = lines.findIndex((line) => RUNTIME_FROM_RE.test(line));
177
+ if (fromIdx === -1) {
178
+ return {
179
+ pinned: false,
180
+ reason: "no `FROM ghcr.io/objectstack-ai/objectstack:<tag>` line found"
181
+ };
182
+ }
183
+ let start = fromIdx;
184
+ while (start > 0 && PROSE_COMMENT_RE.test(lines[start - 1])) start--;
185
+ lines.splice(start, fromIdx - start + 1, ...pinnedComment(version));
186
+ const pinnedText = lines.join("\n");
187
+ const check = pinnedText.split("\n").map((line) => RUNTIME_FROM_RE.exec(line)).find((match) => match !== null);
188
+ if (!check || check[1] !== version) {
189
+ return { pinned: false, reason: "rewrite did not produce the pinned tag" };
190
+ }
191
+ fs3.writeFileSync(dockerfile, pinnedText);
192
+ return { pinned: true, tag: version };
193
+ }
194
+
195
+ // src/index.ts
196
+ var __filename2 = fileURLToPath(import.meta.url);
197
+ var __dirname2 = path4.dirname(__filename2);
198
+ var BUNDLED_TEMPLATES_DIR = path4.resolve(__dirname2, "templates");
150
199
  function toTitleCase(str) {
151
200
  return str.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
152
201
  }
@@ -163,8 +212,8 @@ function sanitizeNamespace(name) {
163
212
  }
164
213
  function readCliVersion() {
165
214
  try {
166
- const pkgPath = path3.resolve(__dirname2, "..", "package.json");
167
- const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
215
+ const pkgPath = path4.resolve(__dirname2, "..", "package.json");
216
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf8"));
168
217
  return String(pkg.version || "0.0.0");
169
218
  } catch {
170
219
  return "0.0.0";
@@ -199,98 +248,50 @@ function detectPackageManager() {
199
248
  }
200
249
  }
201
250
  function loadBundled(templateDir, targetDir) {
202
- const src = path3.join(BUNDLED_TEMPLATES_DIR, templateDir);
203
- if (!fs3.existsSync(src)) {
251
+ const src = path4.join(BUNDLED_TEMPLATES_DIR, templateDir);
252
+ if (!fs4.existsSync(src)) {
204
253
  throw new Error(`Bundled template missing on disk: ${src}`);
205
254
  }
206
255
  const collected = [];
207
256
  copyDir(src, targetDir, collected);
208
257
  return collected;
209
258
  }
210
- async function downloadTarball(url, destFile) {
211
- const res = await fetch(url, { redirect: "follow" });
212
- if (!res.ok || !res.body) {
213
- throw new Error(`Download failed: ${url} (${res.status})`);
214
- }
215
- const out = createWriteStream(destFile);
216
- for await (const chunk of res.body) {
217
- out.write(chunk);
218
- }
219
- await new Promise((resolve, reject) => {
220
- out.end((err) => err ? reject(err) : resolve());
221
- });
222
- }
223
- async function loadRemote(pkgName, targetDir) {
224
- const tmp = await mkdtemp(path3.join(os.tmpdir(), "create-objectstack-"));
225
- try {
226
- const tarball = path3.join(tmp, "templates.tar.gz");
227
- printStep(`Fetching template "${pkgName}" from ${REMOTE_REPO}@${REMOTE_BRANCH}\u2026`);
228
- await downloadTarball(REMOTE_TARBALL_URL, tarball);
229
- fs3.mkdirSync(targetDir, { recursive: true });
230
- const collected = [];
231
- await pipeline(
232
- createReadStream(tarball),
233
- createGunzip(),
234
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
235
- tar.extract({
236
- cwd: targetDir,
237
- strip: 3,
238
- filter: (p) => {
239
- const parts = p.split("/");
240
- return parts[1] === "packages" && parts[2] === pkgName && parts.length > 3;
241
- },
242
- onentry: (entry) => {
243
- if (entry.type === "File") {
244
- const parts = entry.path.split("/").slice(3);
245
- if (parts.length > 0) collected.push(parts.join("/"));
246
- }
247
- }
248
- })
249
- );
250
- if (collected.length === 0) {
251
- throw new Error(
252
- `Template "${pkgName}" not found in ${REMOTE_REPO}@${REMOTE_BRANCH} (expected packages/${pkgName}/).`
253
- );
254
- }
255
- return collected;
256
- } finally {
257
- await rm(tmp, { recursive: true, force: true });
258
- }
259
- }
260
259
  function rewriteProjectIdentity(targetDir, projectName, namespace) {
261
260
  const title = toTitleCase(projectName);
262
261
  const templateNamespace = readTemplateNamespace(targetDir);
263
- const pkgPath = path3.join(targetDir, "package.json");
264
- if (fs3.existsSync(pkgPath)) {
262
+ const pkgPath = path4.join(targetDir, "package.json");
263
+ if (fs4.existsSync(pkgPath)) {
265
264
  try {
266
- const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
265
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf8"));
267
266
  pkg.name = projectName;
268
267
  syncObjectStackDeps(pkg, readCliVersion());
269
- fs3.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
268
+ fs4.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
270
269
  } catch {
271
270
  }
272
271
  }
273
- const manifestPath = path3.join(targetDir, "objectstack.manifest.json");
274
- if (fs3.existsSync(manifestPath)) {
272
+ const manifestPath = path4.join(targetDir, "objectstack.manifest.json");
273
+ if (fs4.existsSync(manifestPath)) {
275
274
  try {
276
- const m = JSON.parse(fs3.readFileSync(manifestPath, "utf8"));
275
+ const m = JSON.parse(fs4.readFileSync(manifestPath, "utf8"));
277
276
  m.name = projectName;
278
277
  m.displayName = title;
279
278
  if ("namespace" in m) m.namespace = namespace;
280
- fs3.writeFileSync(manifestPath, JSON.stringify(m, null, 2) + "\n");
279
+ delete m.description;
280
+ fs4.writeFileSync(manifestPath, JSON.stringify(m, null, 2) + "\n");
281
281
  } catch {
282
282
  }
283
283
  }
284
- const configPath = path3.join(targetDir, "objectstack.config.ts");
285
- if (fs3.existsSync(configPath)) {
286
- let cfg = fs3.readFileSync(configPath, "utf8");
284
+ const configPath = path4.join(targetDir, "objectstack.config.ts");
285
+ if (fs4.existsSync(configPath)) {
286
+ let cfg = fs4.readFileSync(configPath, "utf8");
287
287
  cfg = cfg.replace(/(\bid:\s*)(['"`])[^'"`]*\2/, `$1$2${projectName}$2`);
288
288
  cfg = cfg.replace(/(\bnamespace:\s*)(['"`])[^'"`]*\2/, `$1$2${namespace}$2`);
289
289
  cfg = cfg.replace(/(\bname:\s*)(['"`])[^'"`]*\2/, `$1$2${title}$2`);
290
- fs3.writeFileSync(configPath, cfg);
290
+ cfg = cfg.replace(/^[ \t]*description:\s*(['"`])[^'"`]*\1,?\r?\n/m, "");
291
+ fs4.writeFileSync(configPath, cfg);
291
292
  }
292
293
  if (templateNamespace && namespace !== templateNamespace) {
293
- const srcDir = path3.join(targetDir, "src");
294
+ const srcDir = path4.join(targetDir, "src");
294
295
  rewriteObjectNamePrefix(srcDir, templateNamespace, namespace);
295
296
  const stale = findStaleNamespacePrefixes(srcDir, templateNamespace);
296
297
  if (stale.length > 0) {
@@ -304,77 +305,82 @@ The generated project would fail 'objectstack build' on the \${namespace}_\${sho
304
305
  );
305
306
  }
306
307
  }
307
- const readmePath = path3.join(targetDir, "README.md");
308
- if (fs3.existsSync(readmePath)) {
309
- let md = fs3.readFileSync(readmePath, "utf8");
308
+ const readmePath = path4.join(targetDir, "README.md");
309
+ if (fs4.existsSync(readmePath)) {
310
+ let md = fs4.readFileSync(readmePath, "utf8");
310
311
  md = md.replace(/^#\s+.*$/m, `# ${title}`);
311
- fs3.writeFileSync(readmePath, md);
312
+ fs4.writeFileSync(readmePath, md);
312
313
  }
313
314
  writeAgentGuides(targetDir, title, projectName);
314
315
  }
315
316
  function writeAgentGuides(targetDir, title, projectName) {
316
- const templatePath = path3.join(BUNDLED_TEMPLATES_DIR, "AGENTS.md");
317
+ const templatePath = path4.join(BUNDLED_TEMPLATES_DIR, "AGENTS.md");
317
318
  let template;
318
319
  try {
319
- template = fs3.readFileSync(templatePath, "utf8");
320
+ template = fs4.readFileSync(templatePath, "utf8");
320
321
  } catch (err) {
321
322
  if (err?.code === "ENOENT") return;
322
323
  throw err;
323
324
  }
324
325
  const rendered = template.replace(/\{\{PROJECT_TITLE\}\}/g, title).replace(/\{\{PROJECT_NAME\}\}/g, projectName);
325
- writeIfAbsent(path3.join(targetDir, "AGENTS.md"), rendered);
326
- const copilotPath = path3.join(targetDir, ".github", "copilot-instructions.md");
327
- fs3.mkdirSync(path3.dirname(copilotPath), { recursive: true });
326
+ writeIfAbsent(path4.join(targetDir, "AGENTS.md"), rendered);
327
+ const copilotPath = path4.join(targetDir, ".github", "copilot-instructions.md");
328
+ fs4.mkdirSync(path4.dirname(copilotPath), { recursive: true });
328
329
  writeIfAbsent(copilotPath, rendered);
329
330
  }
330
331
  function writeIfAbsent(filePath, contents) {
331
332
  try {
332
- fs3.writeFileSync(filePath, contents, { flag: "wx" });
333
+ fs4.writeFileSync(filePath, contents, { flag: "wx" });
333
334
  } catch (err) {
334
335
  if (err?.code !== "EEXIST") throw err;
335
336
  }
336
337
  }
337
338
  var program = new Command().name("create-objectstack").description("Create a new ObjectStack environment").version(readCliVersion()).argument("[name]", "Environment name (defaults to current directory name)").option(
338
339
  "-t, --template <template>",
339
- `Template: ${Object.keys(TEMPLATES).join(", ")}`,
340
+ `Template: ${templateNames().join(", ")}`,
340
341
  "blank"
341
- ).option("--skip-install", "Skip dependency installation").option("--skip-skills", "Skip installing ObjectStack AI skills").action(async (name, options) => {
342
+ ).option("--skip-install", "Skip dependency installation").option("--skip-skills", "Skip installing ObjectStack AI skills").action((name, options) => {
342
343
  console.log("");
343
344
  console.log(chalk.bold.cyan(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557"));
344
345
  console.log(chalk.bold.cyan(" \u2551") + chalk.bold(" \u25C6 Create ObjectStack ") + chalk.dim("v6.x") + chalk.bold.cyan(" \u2551"));
345
346
  console.log(chalk.bold.cyan(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D"));
346
347
  printHeader("New Environment");
347
- const template = TEMPLATES[options.template];
348
- if (!template) {
349
- printError(`Unknown template: ${options.template}`);
350
- console.log(chalk.dim(` Available: ${Object.keys(TEMPLATES).join(", ")}`));
348
+ const lookup = lookupTemplate(options.template);
349
+ if (lookup.kind !== "found") {
350
+ if (lookup.kind === "retired") {
351
+ printError(`Template "${lookup.name}" has been retired and is no longer available.`);
352
+ console.log(
353
+ chalk.dim(
354
+ " It was delisted from the ObjectStack template marketplace and is no longer maintained."
355
+ )
356
+ );
357
+ } else {
358
+ printError(`Unknown template: ${lookup.name}`);
359
+ }
360
+ console.log(chalk.dim(` Available: ${templateNames().join(", ")}`));
351
361
  process.exit(1);
352
362
  }
363
+ const template = lookup.template;
353
364
  const cwd = process.cwd();
354
- const projectName = name || path3.basename(cwd);
365
+ const projectName = name || path4.basename(cwd);
355
366
  const namespace = sanitizeNamespace(projectName);
356
- const targetDir = name ? path3.resolve(cwd, name) : cwd;
367
+ const targetDir = name ? path4.resolve(cwd, name) : cwd;
357
368
  const isCurrentDir = targetDir === cwd;
358
369
  printKV("Environment", projectName);
359
370
  printKV("Namespace", namespace);
360
371
  printKV("Template", `${options.template} \u2014 ${template.description}`);
361
372
  printKV("Directory", targetDir);
362
373
  console.log("");
363
- if (!isCurrentDir && fs3.existsSync(targetDir)) {
364
- const existing = fs3.readdirSync(targetDir);
374
+ if (!isCurrentDir && fs4.existsSync(targetDir)) {
375
+ const existing = fs4.readdirSync(targetDir);
365
376
  if (existing.length > 0) {
366
377
  printError(`Directory already exists and is not empty: ${targetDir}`);
367
378
  process.exit(1);
368
379
  }
369
380
  }
370
381
  try {
371
- fs3.mkdirSync(targetDir, { recursive: true });
372
- let createdFiles;
373
- if (template.source.kind === "bundled") {
374
- createdFiles = loadBundled(template.source.dir, targetDir);
375
- } else {
376
- createdFiles = await loadRemote(template.source.pkg, targetDir);
377
- }
382
+ fs4.mkdirSync(targetDir, { recursive: true });
383
+ const createdFiles = loadBundled(template.source.dir, targetDir);
378
384
  rewriteProjectIdentity(targetDir, projectName, namespace);
379
385
  console.log(chalk.bold(" Created files:"));
380
386
  for (const f of createdFiles.slice(0, 20)) {
@@ -386,14 +392,30 @@ var program = new Command().name("create-objectstack").description("Create a new
386
392
  console.log("");
387
393
  if (!options.skipInstall) {
388
394
  printStep("Installing dependencies...");
395
+ let installed = false;
389
396
  try {
390
397
  const pm = detectPackageManager();
391
398
  execSync(`${pm} install`, { stdio: "inherit", cwd: targetDir });
399
+ installed = true;
392
400
  console.log("");
393
401
  } catch {
394
402
  printWarning("Dependency installation failed. Run `npm install` manually.");
395
403
  console.log("");
396
404
  }
405
+ if (installed) {
406
+ const resolved = readResolvedCliVersion(targetDir);
407
+ if (resolved) {
408
+ const result = pinRuntimeImage(targetDir, resolved);
409
+ if (result.pinned) {
410
+ printSuccess(`Dockerfile runtime image pinned to ${result.tag}`);
411
+ } else {
412
+ printWarning(
413
+ `Could not pin the Dockerfile runtime image (${result.reason}); it still reads \`latest\` \u2014 pin it to ${resolved} before deploying.`
414
+ );
415
+ }
416
+ console.log("");
417
+ }
418
+ }
397
419
  }
398
420
  if (!options.skipInstall && !options.skipSkills) {
399
421
  printStep("Installing AI skills for your coding agent...");
@@ -20,9 +20,12 @@ RUN npx os build # → dist/objectstack.json
20
20
  # ── Runtime: the official ObjectStack runtime image ──────────────────
21
21
  # Ships Node + @objectstack/cli with `os start`, a non-root user, the
22
22
  # /api/v1/health HEALTHCHECK, and OS_ARTIFACT_PATH/OS_PORT preset (port 8080)
23
- # — see docker/README.md in the framework repo. Pin the tag to the
24
- # @objectstack/cli version in your package.json so the runtime matches the
25
- # CLI that built the artifact.
23
+ # — see docker/README.md in the framework repo.
24
+ #
25
+ # Dependencies were not installed while scaffolding, so the tag below could
26
+ # not be resolved for you. `latest` floats to whatever release is newest,
27
+ # which is unrelated to the @objectstack/cli your app resolves — pin it to
28
+ # that version, the CLI that builds your artifact, before you deploy.
26
29
  FROM ghcr.io/objectstack-ai/objectstack:latest
27
30
  COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json
28
31
 
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://schemas.objectstack.dev/template-manifest.json",
3
3
  "name": "blank",
4
4
  "namespace": "blank",
5
- "specVersion": "^6.0.0",
5
+ "specVersion": "^17.0.0",
6
6
  "displayName": "Blank Starter",
7
7
  "description": "Minimal ObjectStack environment with a single object — a clean slate for building.",
8
8
  "category": "starter",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-objectstack",
3
- "version": "17.0.0",
3
+ "version": "17.1.0",
4
4
  "description": "Create a new ObjectStack project — npx create-objectstack",
5
5
  "bin": {
6
6
  "create-objectstack": "./bin/create-objectstack.js"
@@ -16,11 +16,10 @@
16
16
  "license": "Apache-2.0",
17
17
  "dependencies": {
18
18
  "chalk": "^6.0.0",
19
- "commander": "^15.0.0",
20
- "tar": "^7.5.22"
19
+ "commander": "^15.0.0"
21
20
  },
22
21
  "devDependencies": {
23
- "@types/node": "^26.1.2",
22
+ "@types/node": "^26.2.0",
24
23
  "tsup": "^8.5.1",
25
24
  "typescript": "^6.0.3",
26
25
  "vitest": "^4.1.10"