Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .changeset/19417-install-door-parses-manifest-id.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/runtime': minor
---
Expand Down Expand Up @@ -34,9 +34,9 @@

```text
Invalid package id 'pkg-a' on `manifest.id`. Expected reverse-domain notation
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments;
hyphens allowed inside a segment, underscores are not. Did you mean
'com.example.pkg-a'?
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments
of letters, digits and inner hyphens; a segment may not open with a hyphen;
underscores are not admitted. Did you mean 'com.example.pkg-a'?
```

**What is not affected.** Boot-time and in-process installs reach
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/metadata-protocol': minor
---
Expand Down Expand Up @@ -42,8 +42,9 @@
```text
Invalid package id 'com.example.my_erp' on `manifest.id`. Expected
reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase
dot-separated segments; hyphens allowed inside a segment, underscores are not.
Did you mean 'com.example.my-erp'?
dot-separated segments of letters, digits and inner hyphens; a segment may not
open with a hyphen; underscores are not admitted. Did you mean
'com.example.my-erp'?
```

**The duplicate door refuses BEFORE it mints anything.** `duplicatePackage`
Expand Down
6 changes: 3 additions & 3 deletions .changeset/19576-install-local-id-gate.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/cloud-connection': minor
---
Expand Down Expand Up @@ -31,9 +31,9 @@

```text
Invalid package id 'late-app' on `manifest.id`. Expected reverse-domain notation
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments;
hyphens allowed inside a segment, underscores are not. Did you mean
'com.example.late-app'?
('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments
of letters, digits and inner hyphens; a segment may not open with a hyphen;
underscores are not admitted. Did you mean 'com.example.late-app'?
```

**`manifest.name` is no longer read as an id.** `ManifestSchema` declares `id`;
Expand Down
19 changes: 19 additions & 0 deletions .changeset/19870-manifest-id-digit-led-segment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
---

feat(spec): a package-id segment may open with a digit, and the refusal states the rule the pattern enforces (#19870)

Clause-②: yes (widening) — the accept set of `ManifestSchema.id` and `PackageSchema.manifestId` (one shared constant, `MANIFEST_ID_PATTERN`) grows by the ids that carry a digit-led segment. Nothing previously admitted is refused, no key is added, removed or renamed, and no export moves.

**The rule, as the pattern now enforces it:** two or more lowercase dot-separated segments of letters, digits and inner hyphens. A segment may open with a letter or a digit — never with a hyphen — and underscores are not admitted. `com.163.crm`, `com.example.2app` and `local.2024-app` are package ids now; `com.example.-app`, `com.example.my_app` and `Com.Example.App` stay refused.

**Why the leading-letter clause went.** The package id is a registry key (`manifest_id`), a runtime package-map key and a grant-source key — never a table name, a JS identifier, a filesystem path or a hostname — and a DNS label may itself open with a digit. The clause had no downstream reason, so the refusal sentence could not state one: an author who followed the sentence could write `com.example.2app`, satisfy every clause it listed, and still be refused.

**The refusal sentence** is now `… Expected reverse-domain notation ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated segments of letters, digits and inner hyphens; a segment may not open with a hyphen; underscores are not admitted.` It was `… — lowercase dot-separated segments; hyphens allowed inside a segment, underscores are not.` The doors that surface it verbatim (`POST /api/v1/packages`, the protocol install and duplicate primitives, `POST /api/v1/marketplace/install-local`, `os package publish`) follow it with no change of their own. A digit-led bare word now gets the prefixed repair (`2fa` → `Did you mean 'com.example.2fa'?`), where it used to get none.

**What moves with it.** `os package publish` derives `local.<slug>` from `manifest.name` or the artifact filename when no id is declared; a slug is lowercase letters, digits and inner hyphens, so a derived id now always parses — a manifest named `2024 App` publishes as `local.2024-app` instead of being refused. A declared `manifest.id` is still used or refused, never replaced by a derived one.

**What does not move.** `manifest.namespace` — the physical object-name prefix (`crm` → `crm_account`) — keeps its own leading-letter rule, for the SQL-identifier reason. A namespace derived from a package id still strips a leading digit run from its last segment (`com.163.crm` → `crm`; `com.example.123` derives none, and the author declares `namespace`).

**If you relied on the refusal:** nothing you stored changes. A consumer that assumed a package id opens each segment with a letter should stop assuming it; parse it through `PackageSchema.shape.manifestId` or `ManifestSchema.shape.id` rather than a copy of the pattern.
30 changes: 16 additions & 14 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1900,7 +1900,7 @@ when both files' `url`s agree.
| `artifact` (positional) | — | Path to the compiled artifact (default `dist/objectstack.json`) |
| `-s, --server <url>` | `OS_CLOUD_URL` | Control-plane URL. Default `https://cloud.objectos.ai`, or the URL recorded by `os cloud login` |
| `-t, --token <key>` | `OS_CLOUD_API_KEY` | Bearer token; `$OS_TOKEN` and `~/.objectstack/cloud.json` are the fallbacks |
| `--manifest-id <id>` | `OS_PACKAGE_MANIFEST_ID` | Reverse-domain package id. Default: `artifact.manifest.id`, else `local.` + a slug of `manifest.name`, else `local.` + a slug of the artifact filename. A derived default is **not guaranteed usable** — see below |
| `--manifest-id <id>` | `OS_PACKAGE_MANIFEST_ID` | Reverse-domain package id. Default: `artifact.manifest.id`, else `local.` + a slug of `manifest.name`, else `local.` + a slug of the artifact filename. A declared `manifest.id` is used or refused, never replaced by a derived one — see below |
| `-v, --version <semver>` | — | Version to publish. Default: `artifact.manifest.version`, else `0.0.0-dev.` + a timestamp |
| `--display-name <name>` | — | Name shown in the Marketplace (default `artifact.manifest.name`) |
| `--description <text>` | — | Short package description |
Expand All @@ -1922,19 +1922,21 @@ when both files' `url`s agree.
| `-n, --note <markdown>` | — | Release notes |
| `--timeout <ms>` | `OS_CLOUD_TIMEOUT_MS` | HTTP timeout in milliseconds, default `120000`. `0` disables it |

**The derived `local.` id is a candidate, not a guaranteed default.** A derived
id is held to exactly the rule an explicit one is — `PackageSchema.manifestId`,
the schema for the very column publish writes — and slugifying has no
letter-first rule, so a manifest named `2024 App` derives `local.2024-app`,
which that schema rejects. `os package publish` then **refuses before any
network call**, quoting the schema and naming which of the three sources above
the id came from, rather than normalising it into a different permanent
identifier or forwarding one the control plane would reject: `manifest_id` is
immutable once published, so an id nobody wrote cannot be renamed afterwards.
The remedy is the one the refusal prints for that source — pass
`--manifest-id`, set `manifestId` in `objectstack.manifest.json`, or declare
`manifest.id` in `objectstack.config.ts` and rebuild. A declared
`manifest.id` is used or refused, never silently replaced by a derived one.
**Every id is held to one rule, whichever source it came from.** An explicit,
declared or derived id is parsed against `PackageSchema.manifestId` — the schema
for the very column publish writes: lowercase dot-separated segments of letters,
digits and inner hyphens, a segment never opening with a hyphen, no
underscores. A slug is lowercase letters, digits and inner hyphens, so a
derived `local.` id always satisfies it — a manifest named `2024 App` derives
`local.2024-app` — and it is published exactly as derived, never normalised
into a different identifier: `manifest_id` is immutable once published, so an
id nobody wrote cannot be renamed afterwards. A declared `manifest.id` is used
or refused, never silently replaced by a derived one: an id that fails the rule
is **refused before any network call**, quoting the schema and naming which of
the three sources above the id came from. The remedy is the one the refusal
prints for that source — pass `--manifest-id`, set `manifestId` in
`objectstack.manifest.json`, or fix `manifest.id` in `objectstack.config.ts`
and rebuild.

**`objectstack.manifest.json` supplies the listing fields.** When that file is
present in the working directory, publish reads `manifestId`, `displayName`,
Expand Down
15 changes: 8 additions & 7 deletions packages/cli/src/commands/package/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ import { readErrorMessage } from '../../utils/response-envelope.js';
* The one rule for a manifest id is `PackageSchema.manifestId` — the schema for
* the very column this command publishes into (`sys_package.manifest_id`).
* Imported, never transcribed: a second, hand-copied rule here is what let this
* preflight admit six shapes the control plane refuses (`com.acme.repair_desk`,
* `COM.ACME.CRM`, `9foo.bar`, `com..acme`, `com.acme.` and — on the explicit
* preflight admit shapes the control plane refuses (`com.acme.repair_desk`,
* `COM.ACME.CRM`, `com..acme`, `com.acme.` and — on the explicit
* `--manifest-id` path — a bare `crm`), while telling the user the contract was
* `a-z0-9._-`. `CreatePackageRequestSchema` in `cloud/package.zod.ts` reaches
* for the same declaration the same way.
Expand Down Expand Up @@ -144,11 +144,12 @@ export interface DerivedManifestId {
*
* ## Steps 2 and 3 are the CLI's own invention
*
* They are **not** guaranteed valid: `slugify` has no letter-first rule, so a
* manifest named `2024 App` derives `local.2024-app`, which the schema rejects.
* That is refused at the same gate with the source named, rather than
* normalised — minting a different permanent identifier than the inputs imply
* is worse than saying what is wrong.
* `slugify` emits lowercase letters, digits and inner hyphens and never opens
* with a hyphen — one legal segment under the declaration, which lets a segment
* open with a digit — so a manifest named `2024 App` derives `local.2024-app`
* and it parses. The derived id still passes through the same gate as every
* other source, and it is never normalised: minting a different permanent
* identifier than the inputs imply is worse than saying what is wrong.
*/
export function deriveManifestId(artifact: any, artifactPath: string): DerivedManifestId {
const explicit = artifact?.manifest?.id;
Expand Down
55 changes: 37 additions & 18 deletions packages/cli/test/package-publish-manifest-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* -> tested against the local rule AND `explicit.includes('.')`.
*
* That extra dot condition is why a bare `crm` was already blocked on the
* derive path while the explicit path let it through: five of the six shapes
* derive path while the explicit path let it through: four of the five shapes
* below held on both paths, `crm` on only one. After the fix both paths ask the
* same schema, and the dot condition is gone because the schema subsumes it
* (its pattern needs at least two segments).
Expand Down Expand Up @@ -70,15 +70,19 @@ const PUBLISH_SRC = resolve(HERE, '../src/commands/package/publish.ts');
const MANIFEST_ID = PackageSchema.shape.manifestId;

/**
* The six shapes the retired local rule admitted, with the pre-fix reading of
* WHICH path admitted each — triage's correction to the card, kept here because
* it is the reason every case below is asserted per path rather than once.
* The shapes the retired local rule admitted and the declaration refuses, with
* the pre-fix reading of WHICH path admitted each — triage's correction to the
* card, kept here because it is the reason every case below is asserted per
* path rather than once.
*
* `9foo.bar` (a digit-first segment) was a sixth row until the declaration let a
* segment open with a digit; the two now agree that it is an id, so it sits
* with the accepted cases below.
*/
const RELAXATIONS: ReadonlyArray<{ id: string; why: string; admittedOnDerivePathBefore: boolean }> = [
{ id: 'crm', why: 'single segment', admittedOnDerivePathBefore: false },
{ id: 'com.acme.repair_desk', why: 'underscore', admittedOnDerivePathBefore: true },
{ id: 'COM.ACME.CRM', why: 'upper case', admittedOnDerivePathBefore: true },
{ id: '9foo.bar', why: 'digit-first segment', admittedOnDerivePathBefore: true },
{ id: 'com..acme', why: 'empty segment', admittedOnDerivePathBefore: true },
{ id: 'com.acme.', why: 'trailing dot', admittedOnDerivePathBefore: true },
];
Expand Down Expand Up @@ -166,6 +170,9 @@ describe('os package publish — the manifest-id rule is the spec manifest-id ru
['local.acme-crm', true],
['a.b', true],
['com.acme.crm-2', true],
['9foo.bar', true],
['com.163.crm', true],
['local.2024-app', true],
...RELAXATIONS.map(({ id }) => [id, false] as const),
['', false],
['com.acme.crm ', false],
Expand Down Expand Up @@ -319,32 +326,44 @@ describe('os package publish — the manifest-id rule is the spec manifest-id ru
});

// -------------------------------------------------------------------------
// The producer half — the CLI also MAKES ids, and slugify has no
// letter-first rule (the filer's addendum).
// The producer half — the CLI also MAKES ids. `slugify` emits lowercase
// letters, digits and inner hyphens and never opens with a hyphen: exactly
// one legal segment, now that a segment may open with a digit. So a derived
// `local.<slug>` id parses, and it is published AS DERIVED.
// -------------------------------------------------------------------------

describe('a derived id the schema rejects is refused, not published and not rewritten', () => {
it("refuses the digit-first id derived from a manifest named '2024 App'", async () => {
describe('a derived id is published as derived, never rewritten', () => {
it("publishes the digit-first id derived from a manifest named '2024 App'", async () => {
// Refused while every segment had to open with a letter; the same bytes
// now reach the wire unchanged.
const derived = deriveManifestId({ manifest: { name: '2024 App' } }, '/nowhere/objectstack.json');
expect(derived).toEqual({ id: 'local.2024-app', source: 'artifact-manifest-name' });
expect(isManifestId(derived.id)).toBe(false);
expect(isManifestId(derived.id)).toBe(true);

const path = await artifactAt({ name: '2024 App', version: '1.2.0' });
const calls = stubCloud();
const { exitCode, output } = await runPublish([path]);

expect(exitCode).toBe(1);
expect(calls).toEqual([]);
expect(output).toContain("Invalid manifest-id 'local.2024-app'");
// The refusal says where the id came from and how to set one, because
// the user never typed this string.
expect(output).toContain('derived from the compiled artifact');
expect(output).toContain('--manifest-id');
expect(exitCode ?? 0).toBe(0);
expect(calls).toHaveLength(2);
expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages');
expect(calls[0].body.manifest_id).toBe('local.2024-app');
expect(output).not.toContain('Invalid manifest-id');
// It is NOT normalised into some other permanent identifier: manifestId
// is immutable once published.
expect(output).not.toContain('local.a2024-app');
});

it('every slug the derivation can produce is a legal id, from the name or from the filename', () => {
const names = ['2024 App', '9', '--x--', 'ünïcödé', 'Acme CRM', `${'a'.repeat(63)} tail`];
for (const name of names) {
const fromName = deriveManifestId({ manifest: { name } }, '/nowhere/objectstack.json');
expect(isManifestId(fromName.id), `${JSON.stringify(name)} -> ${fromName.id}`).toBe(true);
const fromFile = deriveManifestId({ manifest: {} }, `/nowhere/${name}.json`);
expect(isManifestId(fromFile.id), `${JSON.stringify(name)}.json -> ${fromFile.id}`).toBe(true);
}
});

it('publishes when the same derivation lands on a legal id', async () => {
const path = await artifactAt({ name: 'Acme CRM', version: '1.2.0' });
const calls = stubCloud();
Expand Down Expand Up @@ -420,7 +439,7 @@ describe('publish.ts keeps no local copy of the manifest-id rule', () => {
expect(manifestIdShapedLiterals(RETIRED_LOCAL_RULE)).toHaveLength(1);
// …and on a transcription of the schema's own pattern, the shape a
// "fix" that re-types the regex would take.
expect(manifestIdShapedLiterals('const X = /^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$/;')).toHaveLength(1);
expect(manifestIdShapedLiterals('const X = /^[a-z0-9][a-z0-9-]*(\\.[a-z0-9][a-z0-9-]*)+$/;')).toHaveLength(1);
});

it('finds none in the command source', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ afterEach(() => {
const REFUSED_STRINGS: ReadonlyArray<readonly [string, string]> = [
['a bare word (the card\'s probe)', 'late-app'],
['an underscore inside a segment', 'com.example.my_erp'],
['a digit-initial segment', 'com.1acme.crm'],
['a hyphen-initial segment', 'com.-acme.crm'],
['upper case', 'Com.Acme.Crm'],
['a whitespace-padded conforming id (the RAW value is parsed)', ' com.acme.crm '],
['the empty string', ''],
Expand Down Expand Up @@ -222,7 +222,9 @@ describe('install-local parses the inline manifest\'s `id` through its declarati
expectNothingWritten(h);
});

for (const id of ['com.example.crm', 'com.example.my-erp', 'org.apache.superset']) {
// `com.1acme.crm` was a REFUSED row until the declaration let a segment
// open with a digit; it installs now, keyed and filed by the id as written.
for (const id of ['com.example.crm', 'com.example.my-erp', 'org.apache.superset', 'com.1acme.crm']) {
it(`lit control — '${id}' still installs, keyed by the id as written`, async () => {
const h = await mount(dir);
const res = await h.routes.get(`POST ${ROUTE}`)!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ const REFUSED = [
['a bare word — no reverse-domain prefix at all', 'pkg-a'],
['an underscore inside a segment', 'com.example.my_erp'],
['the empty string', ''],
['a segment opening with a digit', 'com.4example.crm'],
['a segment opening with a hyphen', 'com.-example.crm'],
] as const;

/** Ids the declaration admits — the lit controls. */
const ADMITTED = ['com.example.crm', 'com.example.my-erp', 'org.apache.superset'] as const;
/**
* Ids the declaration admits — the lit controls. `com.4example.crm` was a
* REFUSED row until the declaration let a segment open with a digit; it is
* here so this door is held to the widened rule, not only the spec schema.
*/
const ADMITTED = ['com.example.crm', 'com.example.my-erp', 'org.apache.superset', 'com.4example.crm'] as const;

function makeImpl() {
const registryCalls: Array<{ manifest: any }> = [];
Expand Down
Loading
Loading