@indigoai-us/hq-cli 5.35.1 → 5.35.2
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/dist/commands/auth.js +7 -5
- package/dist/commands/cloud-provision.d.ts +13 -4
- package/dist/commands/cloud-provision.js +29 -10
- package/dist/commands/creators.d.ts +34 -0
- package/dist/commands/creators.js +68 -0
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +8 -7
- package/dist/commands/pack-install.js +38 -8
- package/dist/commands/publish.js +8 -2
- package/dist/index.js +5 -2
- package/dist/utils/login-provider.d.ts +11 -0
- package/dist/utils/login-provider.js +28 -0
- package/package.json +1 -1
- package/src/commands/auth.ts +7 -2
- package/src/commands/cloud-provision.test.ts +78 -0
- package/src/commands/cloud-provision.ts +36 -8
- package/src/commands/creators.test.ts +60 -0
- package/src/commands/creators.ts +117 -0
- package/src/commands/login.ts +9 -5
- package/src/commands/marketplace-install.test.ts +129 -0
- package/src/commands/pack-install.test.ts +49 -0
- package/src/commands/pack-install.ts +34 -6
- package/src/commands/publish.test.ts +5 -0
- package/src/commands/publish.ts +8 -0
- package/src/index.ts +3 -0
- package/src/utils/cognito-session.test.ts +3 -3
- package/src/utils/login-provider.test.ts +42 -0
- package/src/utils/login-provider.ts +30 -0
|
@@ -388,11 +388,20 @@ export interface ManifestCompanyEntry {
|
|
|
388
388
|
* under the target slug. Read → mutate → temp-write → rename so concurrent
|
|
389
389
|
* readers never see a partially-written file.
|
|
390
390
|
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
391
|
+
* Skip-if-unchanged: if the target slug already carries the exact
|
|
392
|
+
* `cloud_uid` + `bucket_name`, this is a true no-op — the file is left
|
|
393
|
+
* byte-for-byte intact (comments, ordering, and formatting preserved) and
|
|
394
|
+
* we return `false`. This matters because `yaml.dump` does NOT round-trip
|
|
395
|
+
* comments or the original layout: an unconditional rewrite re-canonicalises
|
|
396
|
+
* the manifest (stripping the `/newcompany` header comment, reflowing keys)
|
|
397
|
+
* on EVERY provision/Connect, even when nothing semantically changed. Each
|
|
398
|
+
* such rewrite is then pushed by the initial-sync step, so the
|
|
399
|
+
* comment-stripped local form perpetually diverges from any peer/cloud copy
|
|
400
|
+
* that still holds the commented form — manifesting as a recurring HQ Sync
|
|
401
|
+
* conflict loop on `companies/manifest.yaml` that re-fires every sync. Only
|
|
402
|
+
* writing when a value actually changes lets the two forms converge.
|
|
393
403
|
*
|
|
394
|
-
* Returns true if the file was written
|
|
395
|
-
* reserved for future "skip if unchanged" optimization).
|
|
404
|
+
* Returns true if the file was written, false if it was already current.
|
|
396
405
|
*/
|
|
397
406
|
export function patchManifest(
|
|
398
407
|
hqRoot: string,
|
|
@@ -408,6 +417,14 @@ export function patchManifest(
|
|
|
408
417
|
// Preserve null / object / unknown — promote null → {} so we can write keys.
|
|
409
418
|
const entry: ManifestCompanyEntry =
|
|
410
419
|
existing && typeof existing === "object" ? { ...existing } : {};
|
|
420
|
+
|
|
421
|
+
// No-op guard: both fields already match → leave the on-disk file (and its
|
|
422
|
+
// comments) untouched so a re-provision can't churn the manifest and seed a
|
|
423
|
+
// sync conflict loop.
|
|
424
|
+
if (entry.cloud_uid === cloudUid && entry.bucket_name === bucketName) {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
411
428
|
entry.cloud_uid = cloudUid;
|
|
412
429
|
entry.bucket_name = bucketName;
|
|
413
430
|
parsed.companies[slug] = entry;
|
|
@@ -757,9 +774,20 @@ export async function provisionCompany(
|
|
|
757
774
|
const bucketName = entity.bucketName;
|
|
758
775
|
const kmsKeyId = entity.kmsKeyId ?? null;
|
|
759
776
|
|
|
760
|
-
// Step 6: patch manifest atomically
|
|
761
|
-
|
|
762
|
-
|
|
777
|
+
// Step 6: patch manifest atomically. Skip-if-unchanged returns false when
|
|
778
|
+
// the manifest already carries this slug's cloud_uid + bucket_name, so we
|
|
779
|
+
// report the honest outcome rather than always claiming a patch.
|
|
780
|
+
const manifestPatched = patchManifest(
|
|
781
|
+
options.hqRoot,
|
|
782
|
+
options.slug,
|
|
783
|
+
cloudUid,
|
|
784
|
+
bucketName,
|
|
785
|
+
);
|
|
786
|
+
log(
|
|
787
|
+
manifestPatched
|
|
788
|
+
? `patched companies/manifest.yaml`
|
|
789
|
+
: `companies/manifest.yaml already current — left untouched`,
|
|
790
|
+
);
|
|
763
791
|
|
|
764
792
|
// Step 7: write .hq/config.json atomically
|
|
765
793
|
writeCompanyConfig(options.hqRoot, options.slug, {
|
|
@@ -821,7 +849,7 @@ export async function provisionCompany(
|
|
|
821
849
|
vault_api_url: options.vaultApiUrl,
|
|
822
850
|
kms_key_id: kmsKeyId,
|
|
823
851
|
created_entity: createdEntity,
|
|
824
|
-
manifest_patched:
|
|
852
|
+
manifest_patched: manifestPatched,
|
|
825
853
|
config_written: true,
|
|
826
854
|
initial_sync: initialSync,
|
|
827
855
|
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { runCreatorApply } from "./creators.js";
|
|
3
|
+
|
|
4
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
5
|
+
return {
|
|
6
|
+
status,
|
|
7
|
+
json: async () => body,
|
|
8
|
+
} as unknown as Response;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("runCreatorApply", () => {
|
|
12
|
+
it("submits the application and reports the application id", async () => {
|
|
13
|
+
const post = vi
|
|
14
|
+
.fn()
|
|
15
|
+
.mockResolvedValue(
|
|
16
|
+
jsonResponse(202, { status: "request_received", applicationId: "capp_1" }),
|
|
17
|
+
);
|
|
18
|
+
const res = await runCreatorApply(
|
|
19
|
+
{ reason: "I build automation skills" },
|
|
20
|
+
{ getAccessToken: async () => "tok", post },
|
|
21
|
+
);
|
|
22
|
+
expect(res.message).toMatch(/submitted \(capp_1\)/);
|
|
23
|
+
expect(post).toHaveBeenCalledWith("tok", { reason: "I build automation skills" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("passes an optional handle through", async () => {
|
|
27
|
+
const post = vi.fn().mockResolvedValue(jsonResponse(202, { applicationId: "capp_2" }));
|
|
28
|
+
await runCreatorApply(
|
|
29
|
+
{ reason: "pitch", handle: " acme " },
|
|
30
|
+
{ getAccessToken: async () => "tok", post },
|
|
31
|
+
);
|
|
32
|
+
expect(post).toHaveBeenCalledWith("tok", { reason: "pitch", handle: "acme" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats a 409 / APPLICATION_PENDING as an already-pending message", async () => {
|
|
36
|
+
const post = vi
|
|
37
|
+
.fn()
|
|
38
|
+
.mockResolvedValue(jsonResponse(409, { code: "APPLICATION_PENDING", applicationId: "capp_3" }));
|
|
39
|
+
const res = await runCreatorApply(
|
|
40
|
+
{ reason: "pitch" },
|
|
41
|
+
{ getAccessToken: async () => "tok", post },
|
|
42
|
+
);
|
|
43
|
+
expect(res.message).toMatch(/already have a pending/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("requires a non-empty reason", async () => {
|
|
47
|
+
const post = vi.fn();
|
|
48
|
+
await expect(
|
|
49
|
+
runCreatorApply({ reason: " " }, { getAccessToken: async () => "tok", post }),
|
|
50
|
+
).rejects.toThrow(/reason is required/i);
|
|
51
|
+
expect(post).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("throws on a hard server error", async () => {
|
|
55
|
+
const post = vi.fn().mockResolvedValue(jsonResponse(500, { error: "boom" }));
|
|
56
|
+
await expect(
|
|
57
|
+
runCreatorApply({ reason: "pitch" }, { getAccessToken: async () => "tok", post }),
|
|
58
|
+
).rejects.toThrow(/boom/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq creators apply` — apply for verified-creator access.
|
|
3
|
+
*
|
|
4
|
+
* Publishing to the marketplace is gated on verified-creator status. An
|
|
5
|
+
* unverified caller submits an application (a short pitch + optional desired
|
|
6
|
+
* handle); it is persisted server-side and an Indigo admin reviews it. On
|
|
7
|
+
* approval the caller becomes a verified creator and can `hq publish`.
|
|
8
|
+
*
|
|
9
|
+
* Wire contract (POST /v1/creators/request-access, authed):
|
|
10
|
+
* body { reason: string, handle?: string }
|
|
11
|
+
* 202 { status, code, applicationId, requestAccessPath }
|
|
12
|
+
* 409 { code: "APPLICATION_PENDING", error, applicationId } (already pending)
|
|
13
|
+
*/
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
17
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
18
|
+
|
|
19
|
+
export interface CreatorApplyOptions {
|
|
20
|
+
reason: string;
|
|
21
|
+
handle?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CreatorApplyDeps {
|
|
25
|
+
/** Resolve a non-expired access token (prompts login when interactive). */
|
|
26
|
+
getAccessToken: () => Promise<string>;
|
|
27
|
+
/** POST the application body to the vault API. */
|
|
28
|
+
post: (token: string, body: Record<string, unknown>) => Promise<Response>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CreatorApplyResult {
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Testable core. Submits the application and maps the server response to a
|
|
37
|
+
* single human-readable message. Throws on hard failures.
|
|
38
|
+
*/
|
|
39
|
+
export async function runCreatorApply(
|
|
40
|
+
opts: CreatorApplyOptions,
|
|
41
|
+
deps: CreatorApplyDeps,
|
|
42
|
+
): Promise<CreatorApplyResult> {
|
|
43
|
+
const reason = opts.reason?.trim();
|
|
44
|
+
if (!reason) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'A reason is required — pass --reason "why you want to publish to the marketplace".',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const token = await deps.getAccessToken();
|
|
51
|
+
const body: Record<string, unknown> = { reason };
|
|
52
|
+
const handle = opts.handle?.trim();
|
|
53
|
+
if (handle) body.handle = handle;
|
|
54
|
+
|
|
55
|
+
const res = await deps.post(token, body);
|
|
56
|
+
const parsed = (await res
|
|
57
|
+
.json()
|
|
58
|
+
.catch(() => ({}))) as Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
if (res.status === 409 || parsed.code === "APPLICATION_PENDING") {
|
|
61
|
+
return {
|
|
62
|
+
message:
|
|
63
|
+
"You already have a pending creator application — an Indigo admin will review it.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (res.status < 200 || res.status >= 300) {
|
|
67
|
+
const msg =
|
|
68
|
+
(parsed.error as string) ?? (parsed.message as string) ?? `HTTP ${res.status}`;
|
|
69
|
+
throw new Error(`Creator application failed: ${msg}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const id =
|
|
73
|
+
typeof parsed.applicationId === "string" ? ` (${parsed.applicationId})` : "";
|
|
74
|
+
return {
|
|
75
|
+
message:
|
|
76
|
+
`Creator application submitted${id} — an Indigo admin will review it. ` +
|
|
77
|
+
"You'll be able to `hq publish` once approved.",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function registerCreatorsCommand(program: Command): void {
|
|
82
|
+
const creators = program
|
|
83
|
+
.command("creators")
|
|
84
|
+
.description("Creator marketplace account");
|
|
85
|
+
|
|
86
|
+
creators
|
|
87
|
+
.command("apply")
|
|
88
|
+
.description(
|
|
89
|
+
"Apply for verified-creator access (required to publish packs)",
|
|
90
|
+
)
|
|
91
|
+
.requiredOption(
|
|
92
|
+
"--reason <reason>",
|
|
93
|
+
"Why you want to publish to the marketplace",
|
|
94
|
+
)
|
|
95
|
+
.option("--handle <handle>", "Desired creator handle (optional)")
|
|
96
|
+
.action(async (options: CreatorApplyOptions) => {
|
|
97
|
+
try {
|
|
98
|
+
const result = await runCreatorApply(
|
|
99
|
+
{ reason: options.reason, handle: options.handle },
|
|
100
|
+
{
|
|
101
|
+
getAccessToken: () => ensureCognitoToken({ interactive: true }),
|
|
102
|
+
post: (token, body) =>
|
|
103
|
+
vaultApiFetch({
|
|
104
|
+
token,
|
|
105
|
+
path: "/v1/creators/request-access",
|
|
106
|
+
method: "POST",
|
|
107
|
+
body,
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
console.log(chalk.green(result.message));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(chalk.red((err as Error).message));
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/commands/login.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hq login — opens browser for Cognito auth
|
|
2
|
+
* hq login — opens browser for Cognito auth.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Command } from 'commander';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { browserLogin, loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
|
|
8
|
-
import {
|
|
8
|
+
import { cognitoConfigForLoginProvider } from '../utils/login-provider.js';
|
|
9
9
|
|
|
10
10
|
function peekIdToken(idToken: string): { email?: string; sub?: string } {
|
|
11
11
|
try {
|
|
@@ -23,8 +23,12 @@ function peekIdToken(idToken: string): { email?: string; sub?: string } {
|
|
|
23
23
|
export function registerLoginCommand(program: Command): void {
|
|
24
24
|
program
|
|
25
25
|
.command('login')
|
|
26
|
-
.description('Authenticate with HQ via Cognito
|
|
27
|
-
.
|
|
26
|
+
.description('Authenticate with HQ via Cognito')
|
|
27
|
+
.option(
|
|
28
|
+
'--provider <provider>',
|
|
29
|
+
'OAuth provider to use: google, microsoft, or picker',
|
|
30
|
+
)
|
|
31
|
+
.action(async (options: { provider?: string }) => {
|
|
28
32
|
try {
|
|
29
33
|
const existing = loadCachedTokens();
|
|
30
34
|
if (existing && !isExpiring(existing, 120)) {
|
|
@@ -34,7 +38,7 @@ export function registerLoginCommand(program: Command): void {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
console.log('Opening browser for authentication...');
|
|
37
|
-
const tokens = await browserLogin(
|
|
41
|
+
const tokens = await browserLogin(cognitoConfigForLoginProvider(options.provider));
|
|
38
42
|
const who = peekIdToken(tokens.idToken).email ?? 'HQ';
|
|
39
43
|
console.log(chalk.green(`Logged in as ${who}`));
|
|
40
44
|
} catch (error) {
|
|
@@ -38,6 +38,30 @@ vi.mock('node:readline', () => ({
|
|
|
38
38
|
}),
|
|
39
39
|
}));
|
|
40
40
|
|
|
41
|
+
// Mock the public listings API so `defaultMarketplaceDeps().resolveListing` is
|
|
42
|
+
// exercised against a controllable feed (BUG-1 exact-slug-match regression).
|
|
43
|
+
// `publicResponder` is read at call time so each test can shape the response —
|
|
44
|
+
// it receives the call options + a zero-based call index so a test can return a
|
|
45
|
+
// different body for the browse call vs. the follow-up detail fetch.
|
|
46
|
+
type PublicResponse = { ok: boolean; status: number; body: unknown };
|
|
47
|
+
let publicResponder: (
|
|
48
|
+
opts: { path: string; query?: Record<string, string> },
|
|
49
|
+
call: number,
|
|
50
|
+
) => PublicResponse = () => ({ ok: true, status: 200, body: { listings: [] } });
|
|
51
|
+
const fetchPublicCalls: Array<{ path: string; query?: Record<string, string> }> = [];
|
|
52
|
+
vi.mock('../utils/vault-api.js', () => ({
|
|
53
|
+
vaultApiFetchPublic: async (opts: { path: string; query?: Record<string, string> }) => {
|
|
54
|
+
const call = fetchPublicCalls.length;
|
|
55
|
+
fetchPublicCalls.push(opts);
|
|
56
|
+
const r = publicResponder(opts, call);
|
|
57
|
+
return {
|
|
58
|
+
ok: r.ok,
|
|
59
|
+
status: r.status,
|
|
60
|
+
json: async () => r.body,
|
|
61
|
+
} as unknown as Response;
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
41
65
|
import {
|
|
42
66
|
classify,
|
|
43
67
|
parseMarketplaceSource,
|
|
@@ -45,6 +69,7 @@ import {
|
|
|
45
69
|
fetchMarketplace,
|
|
46
70
|
resolveLatestMarketplace,
|
|
47
71
|
installPack,
|
|
72
|
+
defaultMarketplaceDeps,
|
|
48
73
|
ArtifactVerificationError,
|
|
49
74
|
type MarketplaceDeps,
|
|
50
75
|
type MarketplaceListing,
|
|
@@ -412,3 +437,107 @@ describe('US-006 REGRESSION: legacy transports still dispatch + install unchange
|
|
|
412
437
|
}
|
|
413
438
|
});
|
|
414
439
|
});
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// BUG-1 — defaultMarketplaceDeps().resolveListing must EXACT-slug-match
|
|
443
|
+
//
|
|
444
|
+
// The browse handler historically ignored `?slug=` and only honored `?q=`
|
|
445
|
+
// (fuzzy), so `?slug=tdd` returned ALL approved listings newest-first. The old
|
|
446
|
+
// code took `listings[0]` → installed the NEWEST pack, not the requested slug
|
|
447
|
+
// (verified live: `marketplace:tdd` installed hq-pack-review). resolveListing
|
|
448
|
+
// must now exact-match `l.slug === slug` and throw when no exact match exists,
|
|
449
|
+
// and must query with BOTH `slug` + `q` for forward/back compatibility.
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
describe('BUG-1 resolveListing exact-slug match (defaultMarketplaceDeps)', () => {
|
|
453
|
+
beforeEach(() => {
|
|
454
|
+
fetchPublicCalls.length = 0;
|
|
455
|
+
publicResponder = () => ({ ok: true, status: 200, body: { listings: [] } });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
/** A browse-summary entry (id present, no URL — detail fetch mints it). */
|
|
459
|
+
function summary(slug: string, version: string, extra?: Record<string, unknown>) {
|
|
460
|
+
return { listingId: `lst_${slug}`, slug, version, status: 'approved', ...extra };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The `GET /v1/listings/{id}` detail the follow-up fetch returns. */
|
|
464
|
+
function detailBody(slug: string, version: string) {
|
|
465
|
+
return {
|
|
466
|
+
listing: {
|
|
467
|
+
id: `lst_${slug}`,
|
|
468
|
+
slug,
|
|
469
|
+
version,
|
|
470
|
+
downloadUrl: 'https://s3.example/presigned?sig=abc',
|
|
471
|
+
contentHash: 'a'.repeat(64),
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
it('returns the EXACTLY-slugged listing — NOT listings[0] — from a multi-pack feed', async () => {
|
|
477
|
+
// Server returns a slug-ignoring fuzzy feed newest-first: review is [0], the
|
|
478
|
+
// requested tdd is later. Old code returned review; the fix returns tdd.
|
|
479
|
+
publicResponder = (_opts, call) =>
|
|
480
|
+
call === 0
|
|
481
|
+
? {
|
|
482
|
+
ok: true,
|
|
483
|
+
status: 200,
|
|
484
|
+
body: { listings: [summary('review', '3.0.0'), summary('tdd', '1.2.0')] },
|
|
485
|
+
}
|
|
486
|
+
: { ok: true, status: 200, body: detailBody('tdd', '1.2.0') };
|
|
487
|
+
|
|
488
|
+
const listing = await defaultMarketplaceDeps().resolveListing('tdd');
|
|
489
|
+
|
|
490
|
+
expect(listing.slug).toBe('tdd');
|
|
491
|
+
expect(listing.listingId).toBe('lst_tdd');
|
|
492
|
+
// The browse query carried BOTH slug (exact) and q (fuzzy fallback).
|
|
493
|
+
expect(fetchPublicCalls[0].query).toMatchObject({ slug: 'tdd', q: 'tdd' });
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('throws a clear error when NO exact-slug match exists (never installs a near-miss)', async () => {
|
|
497
|
+
// Feed contains only OTHER slugs — a slug-ignoring/fuzzy server response.
|
|
498
|
+
publicResponder = () => ({
|
|
499
|
+
ok: true,
|
|
500
|
+
status: 200,
|
|
501
|
+
body: { listings: [summary('review', '3.0.0'), summary('tdd-helper', '1.0.0')] },
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
await expect(defaultMarketplaceDeps().resolveListing('tdd')).rejects.toThrow(
|
|
505
|
+
/No marketplace listing found for slug "tdd"/,
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('with a pinned version, requires BOTH exact slug AND exact version', async () => {
|
|
510
|
+
publicResponder = (_opts, call) =>
|
|
511
|
+
call === 0
|
|
512
|
+
? {
|
|
513
|
+
ok: true,
|
|
514
|
+
status: 200,
|
|
515
|
+
body: {
|
|
516
|
+
listings: [
|
|
517
|
+
summary('tdd', '2.0.0'),
|
|
518
|
+
summary('tdd', '1.5.0'),
|
|
519
|
+
summary('review', '9.9.9'),
|
|
520
|
+
],
|
|
521
|
+
},
|
|
522
|
+
}
|
|
523
|
+
: { ok: true, status: 200, body: detailBody('tdd', '1.5.0') };
|
|
524
|
+
|
|
525
|
+
const listing = await defaultMarketplaceDeps().resolveListing('tdd', '1.5.0');
|
|
526
|
+
|
|
527
|
+
expect(listing.slug).toBe('tdd');
|
|
528
|
+
expect(listing.version).toBe('1.5.0');
|
|
529
|
+
expect(fetchPublicCalls[0].query).toMatchObject({ slug: 'tdd', q: 'tdd', version: '1.5.0' });
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('throws when the pinned version is absent even if the slug matches', async () => {
|
|
533
|
+
publicResponder = () => ({
|
|
534
|
+
ok: true,
|
|
535
|
+
status: 200,
|
|
536
|
+
body: { listings: [summary('tdd', '2.0.0'), summary('tdd', '1.5.0')] },
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await expect(defaultMarketplaceDeps().resolveListing('tdd', '9.9.9')).rejects.toThrow(
|
|
540
|
+
/No marketplace listing found for slug "tdd"@9\.9\.9/,
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
@@ -307,6 +307,55 @@ describe('pack-install: install path layout', () => {
|
|
|
307
307
|
});
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
+
// ---- 7. hqCore host-version check is prerelease-tolerant -------------------
|
|
311
|
+
// BUGFIX: a prerelease host version (e.g. `15.0.9-beta.1`) was rejected by the
|
|
312
|
+
// `requires.hqCore` check because node-semver excludes prereleases from range
|
|
313
|
+
// matching by default. The fix passes `{ includePrerelease: true }` so a beta/rc
|
|
314
|
+
// HQ build can still install packs — while a genuinely-too-old host still fails.
|
|
315
|
+
describe('requires.hqCore prerelease tolerance', () => {
|
|
316
|
+
function writePackRequiring(range: string): string {
|
|
317
|
+
const dir = mkFakePackPayload({ 'knowledge/demo/README.md': '# demo' });
|
|
318
|
+
fs.writeFileSync(
|
|
319
|
+
path.join(dir, 'package.yaml'),
|
|
320
|
+
[
|
|
321
|
+
'name: hq-pack-test',
|
|
322
|
+
'version: 1.0.0',
|
|
323
|
+
"publisher: '@indigoai-us'",
|
|
324
|
+
'access: public',
|
|
325
|
+
'requires:',
|
|
326
|
+
` hqCore: '${range}'`,
|
|
327
|
+
'contributes:',
|
|
328
|
+
' knowledge:',
|
|
329
|
+
' - demo',
|
|
330
|
+
'',
|
|
331
|
+
].join('\n'),
|
|
332
|
+
);
|
|
333
|
+
return dir;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
it('ACCEPTS a prerelease host version against a plain range (>=14.2.0)', () => {
|
|
337
|
+
const dir = writePackRequiring('>=14.2.0');
|
|
338
|
+
const m = validateManifest(dir, '15.0.9-beta.1');
|
|
339
|
+
expect(m.name).toBe('hq-pack-test');
|
|
340
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('still REJECTS a genuinely-too-old host (13.0.0 against >=14.2.0)', () => {
|
|
344
|
+
const dir = writePackRequiring('>=14.2.0');
|
|
345
|
+
expect(() => validateManifest(dir, '13.0.0')).toThrow(
|
|
346
|
+
/does not satisfy pack requirement/,
|
|
347
|
+
);
|
|
348
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('still ACCEPTS a normal (non-prerelease) host version', () => {
|
|
352
|
+
const dir = writePackRequiring('>=14.2.0');
|
|
353
|
+
const m = validateManifest(dir, '15.0.0');
|
|
354
|
+
expect(m.name).toBe('hq-pack-test');
|
|
355
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
310
359
|
it('stampInstallSource preserves a leading `---` document marker — no multi-doc YAML stream', () => {
|
|
311
360
|
// Regression: prepending `source:` before a `---` would split the file
|
|
312
361
|
// into two YAML documents, and single-doc `yaml.load` callers downstream
|
|
@@ -402,9 +402,22 @@ export function defaultMarketplaceDeps(): MarketplaceDeps {
|
|
|
402
402
|
return {
|
|
403
403
|
resolveListing: async (slug, version) => {
|
|
404
404
|
// Search by slug (public). The API returns approved listings only.
|
|
405
|
+
//
|
|
406
|
+
// BUGFIX: the browse handler historically IGNORED `?slug=` and only
|
|
407
|
+
// honored `?q=` (fuzzy), so `?slug=tdd` returned ALL approved listings
|
|
408
|
+
// newest-first — and blindly taking `listings[0]` then installed the
|
|
409
|
+
// newest pack, not the requested slug (e.g. `marketplace:tdd` installed
|
|
410
|
+
// hq-pack-review). We now (a) query with BOTH `slug` (exact, server-side
|
|
411
|
+
// filter being added in parallel) AND `q` (fuzzy fallback) so the right
|
|
412
|
+
// results come back whichever param the server honors, and (b) NEVER take
|
|
413
|
+
// `listings[0]` — we exact-match `l.slug === slug` among the results and
|
|
414
|
+
// throw a clear error if none matches, rather than installing the wrong
|
|
415
|
+
// pack.
|
|
416
|
+
const query: Record<string, string> = { slug, q: slug };
|
|
417
|
+
if (version) query.version = version;
|
|
405
418
|
const res = await vaultApiFetchPublic({
|
|
406
419
|
path: '/v1/listings',
|
|
407
|
-
query
|
|
420
|
+
query,
|
|
408
421
|
});
|
|
409
422
|
if (!res.ok) {
|
|
410
423
|
throw new Error(
|
|
@@ -415,12 +428,22 @@ export function defaultMarketplaceDeps(): MarketplaceDeps {
|
|
|
415
428
|
listings?: RawListingSummary[];
|
|
416
429
|
};
|
|
417
430
|
const listings = body.listings ?? [];
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
431
|
+
// Only consider listings whose slug EXACTLY matches the request — the
|
|
432
|
+
// fuzzy `q` search (and a slug-ignoring browse handler) can return
|
|
433
|
+
// unrelated packs, so an exact-slug filter is the safety floor.
|
|
434
|
+
const exact = listings.filter((l) => l.slug === slug);
|
|
435
|
+
let match: RawListingSummary | undefined;
|
|
436
|
+
if (version) {
|
|
437
|
+
// Pinned `marketplace:<slug>@<version>` → require an exact version too.
|
|
438
|
+
match = exact.find((l) => (l.version ?? l.latestVersion) === version);
|
|
439
|
+
} else {
|
|
440
|
+
// No pin → newest exact-slug match. The feed is newest-first, so the
|
|
441
|
+
// first exact-slug entry is the newest version of that slug.
|
|
442
|
+
match = exact[0];
|
|
443
|
+
}
|
|
421
444
|
if (!match) {
|
|
422
445
|
throw new Error(
|
|
423
|
-
`No
|
|
446
|
+
`No marketplace listing found for slug "${slug}"${version ? `@${version}` : ''}.`,
|
|
424
447
|
);
|
|
425
448
|
}
|
|
426
449
|
const id = match.listingId ?? match.id;
|
|
@@ -998,7 +1021,12 @@ export function validateManifest(
|
|
|
998
1021
|
if (!range || !semverValidRange(range)) {
|
|
999
1022
|
throw new Error(`requires.hqCore must be a valid semver range (got "${range}")`);
|
|
1000
1023
|
}
|
|
1001
|
-
|
|
1024
|
+
// `includePrerelease` so a prerelease host version (e.g. `15.0.9-beta.1`)
|
|
1025
|
+
// satisfies a plain range like `>=14.2.0`. By default node-semver excludes
|
|
1026
|
+
// prereleases from range matching, which would wrongly reject every dogfooding
|
|
1027
|
+
// beta/rc HQ build from installing packs. A genuinely-too-old host (e.g.
|
|
1028
|
+
// `13.0.0`) still fails the range.
|
|
1029
|
+
if (hqVersion && !semverSatisfies(hqVersion, range, { includePrerelease: true })) {
|
|
1002
1030
|
throw new Error(
|
|
1003
1031
|
`Host hqCore ${hqVersion} does not satisfy pack requirement ${range}`
|
|
1004
1032
|
);
|
|
@@ -166,6 +166,11 @@ describe('buildListingNotice', () => {
|
|
|
166
166
|
it('maps 401/403 to an auth error', () => {
|
|
167
167
|
expect(() => buildListingNotice(401, {}, 'hq-pack-demo', '1.0.0')).toThrow(/Not authorized|login/i);
|
|
168
168
|
});
|
|
169
|
+
it('maps a 403 NOT_VERIFIED_CREATOR to the apply guidance', () => {
|
|
170
|
+
expect(() =>
|
|
171
|
+
buildListingNotice(403, { code: 'NOT_VERIFIED_CREATOR' }, 'hq-pack-demo', '1.0.0'),
|
|
172
|
+
).toThrow(/hq creators apply/i);
|
|
173
|
+
});
|
|
169
174
|
it('surfaces a generic server error body', () => {
|
|
170
175
|
expect(() => buildListingNotice(500, { error: 'boom' }, 'hq-pack-demo', '1.0.0')).toThrow(/boom/);
|
|
171
176
|
});
|
package/src/commands/publish.ts
CHANGED
|
@@ -315,6 +315,14 @@ export function buildListingNotice(
|
|
|
315
315
|
);
|
|
316
316
|
}
|
|
317
317
|
if (status === 401 || status === 403) {
|
|
318
|
+
const code = typeof body.code === 'string' ? body.code : '';
|
|
319
|
+
if (code === 'NOT_VERIFIED_CREATOR') {
|
|
320
|
+
throw new Error(
|
|
321
|
+
'Only verified creators can publish to the marketplace. ' +
|
|
322
|
+
'Run `hq creators apply --reason "..."` to request verified-creator access; ' +
|
|
323
|
+
'an Indigo admin will review it.',
|
|
324
|
+
);
|
|
325
|
+
}
|
|
318
326
|
throw new Error('Not authorized to publish — run `hq login` and ensure your creator account is verified.');
|
|
319
327
|
}
|
|
320
328
|
if (status < 200 || status >= 300) {
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { registerPackageUpdateCommand } from "./commands/pkg-update.js";
|
|
|
25
25
|
import { registerPackageListCommand } from "./commands/pkg-list.js";
|
|
26
26
|
import { registerPacksCommand } from "./commands/packs.js";
|
|
27
27
|
import { registerPublishCommand } from "./commands/publish.js";
|
|
28
|
+
import { registerCreatorsCommand } from "./commands/creators.js";
|
|
28
29
|
import { registerTeamSyncCommand } from "./commands/team-sync.js";
|
|
29
30
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
30
31
|
import { registerSecretsCommand } from "./commands/secrets.js";
|
|
@@ -108,6 +109,8 @@ registerPackageRemoveCommand(program);
|
|
|
108
109
|
// "hq publish <skill-or-worker-path>" packages and submits a pack to the
|
|
109
110
|
// marketplace via POST /v1/listings.
|
|
110
111
|
registerPublishCommand(program);
|
|
112
|
+
// `hq creators apply` — request verified-creator access (required to publish).
|
|
113
|
+
registerCreatorsCommand(program);
|
|
111
114
|
|
|
112
115
|
// Cloud sync subcommand group
|
|
113
116
|
const syncCmd = program
|
|
@@ -50,10 +50,10 @@ describe("resolveDefaultHqRoot", () => {
|
|
|
50
50
|
// companies/ already exists (we mkdir'd nested inside it)
|
|
51
51
|
|
|
52
52
|
process.chdir(nested);
|
|
53
|
-
expect(resolveDefaultHqRoot()).toBe(hqDir);
|
|
53
|
+
expect(resolveDefaultHqRoot()).toBe(realpathSync(hqDir));
|
|
54
54
|
|
|
55
55
|
process.chdir(hqDir);
|
|
56
|
-
expect(resolveDefaultHqRoot()).toBe(hqDir);
|
|
56
|
+
expect(resolveDefaultHqRoot()).toBe(realpathSync(hqDir));
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
it("priority 2: SKIPS a nested core/core.yaml without a sibling companies/ dir (Codex P2 on hq#146)", () => {
|
|
@@ -72,7 +72,7 @@ describe("resolveDefaultHqRoot", () => {
|
|
|
72
72
|
process.chdir(cwdInsideCore);
|
|
73
73
|
// Should walk PAST the inner core/core.yaml (no companies/ sibling) and
|
|
74
74
|
// resolve to the real hqDir.
|
|
75
|
-
expect(resolveDefaultHqRoot()).toBe(hqDir);
|
|
75
|
+
expect(resolveDefaultHqRoot()).toBe(realpathSync(hqDir));
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
it("priority 3: falls back when neither $HQ_ROOT nor an HQ-root marker pair are found", () => {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("./cognito-session.js", () => ({
|
|
4
|
+
DEFAULT_COGNITO: {
|
|
5
|
+
region: "us-east-1",
|
|
6
|
+
userPoolDomain: "vault-indigo-hq-prod",
|
|
7
|
+
clientId: "client-123",
|
|
8
|
+
port: 8765,
|
|
9
|
+
identityProvider: "Google",
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
cognitoConfigForLoginProvider,
|
|
15
|
+
parseLoginProvider,
|
|
16
|
+
} from "./login-provider.js";
|
|
17
|
+
|
|
18
|
+
describe("login provider helpers", () => {
|
|
19
|
+
it("preserves the default Cognito config when no provider is supplied", () => {
|
|
20
|
+
expect(cognitoConfigForLoginProvider(undefined).identityProvider).toBe("Google");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps Microsoft to the Cognito MicrosoftPersonal IdP", () => {
|
|
24
|
+
expect(cognitoConfigForLoginProvider("microsoft").identityProvider).toBe(
|
|
25
|
+
"MicrosoftPersonal",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("maps picker to no forced IdP", () => {
|
|
30
|
+
expect(cognitoConfigForLoginProvider("picker").identityProvider).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("accepts provider values case-insensitively", () => {
|
|
34
|
+
expect(parseLoginProvider("Microsoft")).toBe("microsoft");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects unknown providers", () => {
|
|
38
|
+
expect(() => parseLoginProvider("github")).toThrow(
|
|
39
|
+
"Provider must be one of: google, microsoft, picker",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|