@indigoai-us/hq-cli 5.33.0 → 5.34.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/dist/commands/__fixtures__/make-tar.d.ts +46 -0
- package/dist/commands/__fixtures__/make-tar.js +105 -0
- package/dist/commands/master-sync.d.ts +11 -0
- package/dist/commands/master-sync.js +15 -0
- package/dist/commands/pack-install.d.ts +187 -1
- package/dist/commands/pack-install.js +405 -16
- package/dist/commands/packs.js +9 -4
- package/dist/commands/publish.d.ts +186 -0
- package/dist/commands/publish.js +375 -0
- package/dist/commands/rescue.d.ts +33 -0
- package/dist/commands/rescue.js +161 -0
- package/dist/commands/safe-extract.d.ts +154 -0
- package/dist/commands/safe-extract.js +347 -0
- package/dist/index.js +17 -2
- package/dist/lib/local-tree-diff.d.ts +21 -0
- package/dist/lib/local-tree-diff.js +18 -3
- package/dist/types.d.ts +22 -0
- package/dist/utils/vault-api.d.ts +11 -0
- package/dist/utils/vault-api.js +39 -2
- package/package.json +2 -2
- package/src/commands/__fixtures__/make-tar.ts +126 -0
- package/src/commands/artifact-verify.test.ts +177 -0
- package/src/commands/marketplace-install.test.ts +414 -0
- package/src/commands/marketplace-security.test.ts +646 -0
- package/src/commands/master-sync.ts +23 -0
- package/src/commands/pack-install.test.ts +209 -1
- package/src/commands/pack-install.ts +617 -15
- package/src/commands/packs.ts +8 -1
- package/src/commands/publish.test.ts +538 -0
- package/src/commands/publish.ts +517 -0
- package/src/commands/rescue.test.ts +39 -0
- package/src/commands/rescue.ts +210 -0
- package/src/commands/safe-extract.test.ts +459 -0
- package/src/commands/safe-extract.ts +444 -0
- package/src/index.ts +18 -0
- package/src/lib/local-tree-diff.test.ts +19 -0
- package/src/lib/local-tree-diff.ts +17 -1
- package/src/types.ts +23 -0
- package/src/utils/vault-api.ts +41 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq publish <skill-or-worker-path> — marketplace packer + authenticated upload
|
|
3
|
+
* (US-004, hq-creator-marketplace).
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. Refuse if not logged in (cached Cognito session absent/expired) — uploads
|
|
7
|
+
* nothing. The author uid is read from the cached idToken's `sub`.
|
|
8
|
+
* 2. Validate package.yaml with the canonical 10-rule validator
|
|
9
|
+
* (`validateManifest` from pack-install.ts) — never duplicate that logic.
|
|
10
|
+
* On failure, print the validation error and upload nothing.
|
|
11
|
+
* 3. Stamp `author` (uid from the idToken `sub`, plus handle/displayName) into
|
|
12
|
+
* the pack's package.yaml (AC1). The server re-derives author from the
|
|
13
|
+
* Cognito sub too, but the CLI stamps it so the on-disk + published pack
|
|
14
|
+
* carry attribution.
|
|
15
|
+
* 4. Tar (gzip) the payload, base64-encode the bytes, and POST a JSON body to
|
|
16
|
+
* `/v1/listings` via vaultApiFetch with the access token. The deployed
|
|
17
|
+
* server JSON.parses the body and expects
|
|
18
|
+
* { type, name, slug, version, summary?, contributes(string)?,
|
|
19
|
+
* creatorHandle?, tarball(base64) }, returning the created listing id +
|
|
20
|
+
* `pending_review` status. A 409 (re-publishing the same version) is
|
|
21
|
+
* surfaced as a clear duplicate error.
|
|
22
|
+
* 5. Register/update `modules.yaml` provenance for the published source.
|
|
23
|
+
*
|
|
24
|
+
* Reuses the existing CLI plumbing:
|
|
25
|
+
* - cognito-session.ts (token cache / access token)
|
|
26
|
+
* - vault-api.ts (vaultApiFetch — authed JSON POST to /v1/listings)
|
|
27
|
+
* - pack-install.ts:validateManifest (the 10 validation rules)
|
|
28
|
+
* - manifest.ts (modules.yaml read/write for provenance)
|
|
29
|
+
*/
|
|
30
|
+
import { Command } from 'commander';
|
|
31
|
+
import type { PackManifest, PackModuleDefinition, ModulesManifest } from '../types.js';
|
|
32
|
+
export interface IdTokenClaims {
|
|
33
|
+
sub?: string;
|
|
34
|
+
email?: string;
|
|
35
|
+
/** Cognito custom attributes / standard claims used for attribution. */
|
|
36
|
+
'cognito:username'?: string;
|
|
37
|
+
preferred_username?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Decode a Cognito idToken's payload (no signature verification — this is a
|
|
42
|
+
* local read of an already-trusted cached token, mirroring whoami.ts/login.ts).
|
|
43
|
+
* Pure → unit-testable.
|
|
44
|
+
*/
|
|
45
|
+
export declare function peekIdToken(idToken: string): IdTokenClaims;
|
|
46
|
+
export interface ResolvedAuthor {
|
|
47
|
+
uid: string;
|
|
48
|
+
handle: string;
|
|
49
|
+
displayName: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the pack `author` block from idToken claims. `uid` is the Cognito
|
|
53
|
+
* `sub` (AC1). `handle`/`displayName` fall back gracefully so a thin token
|
|
54
|
+
* still produces a well-shaped author that passes `validateManifest`. Throws
|
|
55
|
+
* if there is no `sub` (caller is treated as logged-out). Pure.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveAuthor(claims: IdTokenClaims): ResolvedAuthor;
|
|
58
|
+
/**
|
|
59
|
+
* Stamp `author` into a parsed manifest object, returning the YAML text to
|
|
60
|
+
* write. Existing `author` is overwritten with the resolved attribution so the
|
|
61
|
+
* publisher's own identity always wins (the server enforces this too). Pure.
|
|
62
|
+
*/
|
|
63
|
+
export declare function stampAuthorYaml(manifest: Record<string, unknown>, author: ResolvedAuthor): string;
|
|
64
|
+
/** Read package.yaml, stamp author, write it back. Returns the stamped author. */
|
|
65
|
+
export declare function stampAuthorIntoPackage(payloadDir: string, author: ResolvedAuthor): void;
|
|
66
|
+
/**
|
|
67
|
+
* Create a gzipped tarball of `payloadDir` and return its bytes. Uses the same
|
|
68
|
+
* `tar` argv approach as pack-install.ts (no shell, no new dep). The archive is
|
|
69
|
+
* written to a tmp file, read back, then removed.
|
|
70
|
+
*/
|
|
71
|
+
export declare function tarballPayload(payloadDir: string): Uint8Array;
|
|
72
|
+
/** The marketplace listing type the server enforces (VALID_TYPES). */
|
|
73
|
+
export type ListingType = 'skill' | 'worker';
|
|
74
|
+
/**
|
|
75
|
+
* Infer the listing `type` the server requires (exactly "skill" | "worker").
|
|
76
|
+
*
|
|
77
|
+
* Rules:
|
|
78
|
+
* - An explicit `type` hint in package.yaml wins if it is already a valid
|
|
79
|
+
* listing type (lets a pack author override the inference deterministically).
|
|
80
|
+
* - Otherwise: a pack that contributes workers → "worker"; one that
|
|
81
|
+
* contributes skills → "skill".
|
|
82
|
+
* - Ambiguous/both/neither → "skill" (the documented default).
|
|
83
|
+
* Pure → unit-testable.
|
|
84
|
+
*/
|
|
85
|
+
export declare function inferListingType(pkg: PackManifest): ListingType;
|
|
86
|
+
/**
|
|
87
|
+
* Derive the marketplace slug from the pack name. By convention pack names are
|
|
88
|
+
* `^hq-pack-[a-z0-9][a-z0-9-]*$`, so we strip the leading `hq-pack-` to get a
|
|
89
|
+
* short, valid, non-empty slug (e.g. `hq-pack-demo` → `demo`). If the name does
|
|
90
|
+
* not carry that prefix we fall back to the full name. Pure.
|
|
91
|
+
*/
|
|
92
|
+
export declare function deriveSlug(packName: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* Build the SHORT STRING `contributes` summary the server expects (it stores a
|
|
95
|
+
* string, NOT the structured object). Lists each non-empty contribute category
|
|
96
|
+
* with its entries, e.g. "workers: alpha, beta; skills: demo". Pure.
|
|
97
|
+
*/
|
|
98
|
+
export declare function summarizeContributes(contributes: PackManifest['contributes']): string;
|
|
99
|
+
/**
|
|
100
|
+
* Register or update a `package`-strategy module entry in modules.yaml so the
|
|
101
|
+
* published source has on-disk provenance (AC4). Returns the updated manifest.
|
|
102
|
+
* Pure (operates on an in-memory manifest); the disk read/write is done by the
|
|
103
|
+
* caller via `recordProvenance`.
|
|
104
|
+
*/
|
|
105
|
+
export declare function upsertPackProvenance(manifest: ModulesManifest | null, entry: PackModuleDefinition): ModulesManifest;
|
|
106
|
+
/** Read modules.yaml, upsert the provenance entry, write it back. */
|
|
107
|
+
export declare function recordProvenance(hqRoot: string, pkg: PackManifest, source: string): void;
|
|
108
|
+
export interface ListingResponse {
|
|
109
|
+
id?: string;
|
|
110
|
+
listingId?: string;
|
|
111
|
+
status?: string;
|
|
112
|
+
/** Server-resolved creator handle (prefers the caller's claimed handle). */
|
|
113
|
+
creatorHandle?: string;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Unwrap the `POST /v1/listings` SUCCESS envelope. The deployed server returns
|
|
117
|
+
* the created listing WRAPPED: `{ listing: { listingId, creatorHandle, status,
|
|
118
|
+
* … } }`. Older/mocked responses return the listing fields at the TOP level.
|
|
119
|
+
* This reads `body.listing` when present and falls back to the top-level `body`
|
|
120
|
+
* for backwards-compat. Pure.
|
|
121
|
+
*/
|
|
122
|
+
export declare function unwrapListing(body: Record<string, unknown>): ListingResponse;
|
|
123
|
+
/**
|
|
124
|
+
* Map a `POST /v1/listings` response into a user-facing success notice. Pure.
|
|
125
|
+
* Throws a clear error on a duplicate (409) or any non-2xx status so the caller
|
|
126
|
+
* prints it and exits non-zero.
|
|
127
|
+
*
|
|
128
|
+
* The notice uses the REAL listing id and the SERVER-resolved `creatorHandle`
|
|
129
|
+
* from the (possibly `{ listing }`-wrapped) response body — not the
|
|
130
|
+
* locally-resolved author handle — falling back gracefully when the server
|
|
131
|
+
* omits either field.
|
|
132
|
+
*/
|
|
133
|
+
export declare function buildListingNotice(status: number, body: Record<string, unknown>, packName: string, packVersion: string): string;
|
|
134
|
+
/**
|
|
135
|
+
* The exact JSON body the deployed `POST /v1/listings` handler expects. Field
|
|
136
|
+
* shapes mirror the server contract: `type` ∈ {skill,worker}, `contributes` is
|
|
137
|
+
* a STRING summary (not the structured object), `tarball` is base64 gzip bytes.
|
|
138
|
+
*/
|
|
139
|
+
export interface ListingRequest {
|
|
140
|
+
type: ListingType;
|
|
141
|
+
name: string;
|
|
142
|
+
slug: string;
|
|
143
|
+
version: string;
|
|
144
|
+
summary?: string;
|
|
145
|
+
contributes?: string;
|
|
146
|
+
creatorHandle?: string;
|
|
147
|
+
/** Base64-encoded gzip pack tarball (pack.tar.gz bytes). */
|
|
148
|
+
tarball: string;
|
|
149
|
+
}
|
|
150
|
+
export interface PublishDeps {
|
|
151
|
+
/** Returns the cached idToken, or null/expired if not logged in. */
|
|
152
|
+
loadIdToken: () => string | null;
|
|
153
|
+
/** Returns a non-interactive access token. */
|
|
154
|
+
getAccessToken: () => Promise<string>;
|
|
155
|
+
/**
|
|
156
|
+
* POSTs the listing as a JSON body to `/v1/listings` and returns the parsed
|
|
157
|
+
* { status, body }. `tarball` is the BASE64-encoded gzip pack bytes — the
|
|
158
|
+
* server JSON.parses the body and base64-decodes the tarball.
|
|
159
|
+
*/
|
|
160
|
+
upload: (args: {
|
|
161
|
+
token: string;
|
|
162
|
+
listing: ListingRequest;
|
|
163
|
+
}) => Promise<{
|
|
164
|
+
status: number;
|
|
165
|
+
body: Record<string, unknown>;
|
|
166
|
+
}>;
|
|
167
|
+
hqRoot: string;
|
|
168
|
+
/** Reads core.yaml hqVersion for the validator (null when absent). */
|
|
169
|
+
hqVersion: string | null;
|
|
170
|
+
}
|
|
171
|
+
export interface PublishResult {
|
|
172
|
+
notice: string;
|
|
173
|
+
author: ResolvedAuthor;
|
|
174
|
+
pkg: PackManifest;
|
|
175
|
+
/** True when the success notice already carries the server's attribution. */
|
|
176
|
+
serverAttributed: boolean;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Core publish logic, dependency-injected so it can be unit-tested with mocked
|
|
180
|
+
* network + a tmp filesystem (no real API, no browser login). Returns the
|
|
181
|
+
* success notice; throws (uploading nothing) on logged-out or validation
|
|
182
|
+
* failure.
|
|
183
|
+
*/
|
|
184
|
+
export declare function runPublish(targetPath: string, deps: PublishDeps): Promise<PublishResult>;
|
|
185
|
+
export declare function registerPublishCommand(program: Command): void;
|
|
186
|
+
//# sourceMappingURL=publish.d.ts.map
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq publish <skill-or-worker-path> — marketplace packer + authenticated upload
|
|
3
|
+
* (US-004, hq-creator-marketplace).
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. Refuse if not logged in (cached Cognito session absent/expired) — uploads
|
|
7
|
+
* nothing. The author uid is read from the cached idToken's `sub`.
|
|
8
|
+
* 2. Validate package.yaml with the canonical 10-rule validator
|
|
9
|
+
* (`validateManifest` from pack-install.ts) — never duplicate that logic.
|
|
10
|
+
* On failure, print the validation error and upload nothing.
|
|
11
|
+
* 3. Stamp `author` (uid from the idToken `sub`, plus handle/displayName) into
|
|
12
|
+
* the pack's package.yaml (AC1). The server re-derives author from the
|
|
13
|
+
* Cognito sub too, but the CLI stamps it so the on-disk + published pack
|
|
14
|
+
* carry attribution.
|
|
15
|
+
* 4. Tar (gzip) the payload, base64-encode the bytes, and POST a JSON body to
|
|
16
|
+
* `/v1/listings` via vaultApiFetch with the access token. The deployed
|
|
17
|
+
* server JSON.parses the body and expects
|
|
18
|
+
* { type, name, slug, version, summary?, contributes(string)?,
|
|
19
|
+
* creatorHandle?, tarball(base64) }, returning the created listing id +
|
|
20
|
+
* `pending_review` status. A 409 (re-publishing the same version) is
|
|
21
|
+
* surfaced as a clear duplicate error.
|
|
22
|
+
* 5. Register/update `modules.yaml` provenance for the published source.
|
|
23
|
+
*
|
|
24
|
+
* Reuses the existing CLI plumbing:
|
|
25
|
+
* - cognito-session.ts (token cache / access token)
|
|
26
|
+
* - vault-api.ts (vaultApiFetch — authed JSON POST to /v1/listings)
|
|
27
|
+
* - pack-install.ts:validateManifest (the 10 validation rules)
|
|
28
|
+
* - manifest.ts (modules.yaml read/write for provenance)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="73704bc7-d7b1-5c4c-a34e-54edadcc2753")}catch(e){}}();
|
|
32
|
+
import * as fs from 'fs';
|
|
33
|
+
import * as os from 'os';
|
|
34
|
+
import * as path from 'path';
|
|
35
|
+
import * as yaml from 'js-yaml';
|
|
36
|
+
import { execFileSync } from 'child_process';
|
|
37
|
+
import chalk from 'chalk';
|
|
38
|
+
import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
|
|
39
|
+
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
40
|
+
import { vaultApiFetch } from '../utils/vault-api.js';
|
|
41
|
+
import { validateManifest } from './pack-install.js';
|
|
42
|
+
import { findHqRoot, readManifest, writeManifest } from '../utils/manifest.js';
|
|
43
|
+
/**
|
|
44
|
+
* Decode a Cognito idToken's payload (no signature verification — this is a
|
|
45
|
+
* local read of an already-trusted cached token, mirroring whoami.ts/login.ts).
|
|
46
|
+
* Pure → unit-testable.
|
|
47
|
+
*/
|
|
48
|
+
export function peekIdToken(idToken) {
|
|
49
|
+
try {
|
|
50
|
+
const payload = idToken.split('.')[1];
|
|
51
|
+
if (!payload)
|
|
52
|
+
return {};
|
|
53
|
+
const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
|
|
54
|
+
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
55
|
+
const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
|
|
56
|
+
return decoded;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the pack `author` block from idToken claims. `uid` is the Cognito
|
|
64
|
+
* `sub` (AC1). `handle`/`displayName` fall back gracefully so a thin token
|
|
65
|
+
* still produces a well-shaped author that passes `validateManifest`. Throws
|
|
66
|
+
* if there is no `sub` (caller is treated as logged-out). Pure.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveAuthor(claims) {
|
|
69
|
+
const uid = claims.sub?.trim();
|
|
70
|
+
if (!uid) {
|
|
71
|
+
throw new Error('Cached session has no subject (sub) claim — run `hq login` again.');
|
|
72
|
+
}
|
|
73
|
+
const handle = claims.preferred_username?.trim() ||
|
|
74
|
+
claims['cognito:username']?.trim() ||
|
|
75
|
+
claims.email?.split('@')[0]?.trim() ||
|
|
76
|
+
uid;
|
|
77
|
+
const displayName = claims.name?.trim() || claims.email?.trim() || handle;
|
|
78
|
+
return { uid, handle, displayName };
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// package.yaml author stamping
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* Stamp `author` into a parsed manifest object, returning the YAML text to
|
|
85
|
+
* write. Existing `author` is overwritten with the resolved attribution so the
|
|
86
|
+
* publisher's own identity always wins (the server enforces this too). Pure.
|
|
87
|
+
*/
|
|
88
|
+
export function stampAuthorYaml(manifest, author) {
|
|
89
|
+
const stamped = { ...manifest, author };
|
|
90
|
+
return yaml.dump(stamped, { lineWidth: -1 });
|
|
91
|
+
}
|
|
92
|
+
/** Read package.yaml, stamp author, write it back. Returns the stamped author. */
|
|
93
|
+
export function stampAuthorIntoPackage(payloadDir, author) {
|
|
94
|
+
const manifestPath = path.join(payloadDir, 'package.yaml');
|
|
95
|
+
const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
|
|
96
|
+
fs.writeFileSync(manifestPath, stampAuthorYaml(parsed, author));
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Tarball
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* Create a gzipped tarball of `payloadDir` and return its bytes. Uses the same
|
|
103
|
+
* `tar` argv approach as pack-install.ts (no shell, no new dep). The archive is
|
|
104
|
+
* written to a tmp file, read back, then removed.
|
|
105
|
+
*/
|
|
106
|
+
export function tarballPayload(payloadDir) {
|
|
107
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-publish-tar-'));
|
|
108
|
+
const tarPath = path.join(tmpDir, 'pack.tar.gz');
|
|
109
|
+
try {
|
|
110
|
+
// `-C payloadDir .` packs the directory contents at the archive root
|
|
111
|
+
// (package.yaml + skills/ etc. at top level), excluding VCS/junk.
|
|
112
|
+
execFileSync('tar', [
|
|
113
|
+
'-czf',
|
|
114
|
+
tarPath,
|
|
115
|
+
'-C',
|
|
116
|
+
payloadDir,
|
|
117
|
+
'--exclude=.git',
|
|
118
|
+
'--exclude=node_modules',
|
|
119
|
+
'--exclude=.DS_Store',
|
|
120
|
+
'.',
|
|
121
|
+
], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
122
|
+
return fs.readFileSync(tarPath);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Infer the listing `type` the server requires (exactly "skill" | "worker").
|
|
130
|
+
*
|
|
131
|
+
* Rules:
|
|
132
|
+
* - An explicit `type` hint in package.yaml wins if it is already a valid
|
|
133
|
+
* listing type (lets a pack author override the inference deterministically).
|
|
134
|
+
* - Otherwise: a pack that contributes workers → "worker"; one that
|
|
135
|
+
* contributes skills → "skill".
|
|
136
|
+
* - Ambiguous/both/neither → "skill" (the documented default).
|
|
137
|
+
* Pure → unit-testable.
|
|
138
|
+
*/
|
|
139
|
+
export function inferListingType(pkg) {
|
|
140
|
+
const hint = pkg.type;
|
|
141
|
+
if (hint === 'skill' || hint === 'worker')
|
|
142
|
+
return hint;
|
|
143
|
+
const contributes = pkg.contributes ?? {};
|
|
144
|
+
const hasWorkers = (contributes.workers?.length ?? 0) > 0;
|
|
145
|
+
const hasSkills = (contributes.skills?.length ?? 0) > 0;
|
|
146
|
+
// Prefer skill when ambiguous (both) or when neither is present.
|
|
147
|
+
if (hasWorkers && !hasSkills)
|
|
148
|
+
return 'worker';
|
|
149
|
+
return 'skill';
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Derive the marketplace slug from the pack name. By convention pack names are
|
|
153
|
+
* `^hq-pack-[a-z0-9][a-z0-9-]*$`, so we strip the leading `hq-pack-` to get a
|
|
154
|
+
* short, valid, non-empty slug (e.g. `hq-pack-demo` → `demo`). If the name does
|
|
155
|
+
* not carry that prefix we fall back to the full name. Pure.
|
|
156
|
+
*/
|
|
157
|
+
export function deriveSlug(packName) {
|
|
158
|
+
const stripped = packName.replace(/^hq-pack-/, '');
|
|
159
|
+
return stripped.length > 0 ? stripped : packName;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Build the SHORT STRING `contributes` summary the server expects (it stores a
|
|
163
|
+
* string, NOT the structured object). Lists each non-empty contribute category
|
|
164
|
+
* with its entries, e.g. "workers: alpha, beta; skills: demo". Pure.
|
|
165
|
+
*/
|
|
166
|
+
export function summarizeContributes(contributes) {
|
|
167
|
+
const parts = [];
|
|
168
|
+
for (const [key, entries] of Object.entries(contributes ?? {})) {
|
|
169
|
+
if (Array.isArray(entries) && entries.length > 0) {
|
|
170
|
+
parts.push(`${key}: ${entries.join(', ')}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return parts.join('; ');
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Provenance — modules.yaml upsert
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
/**
|
|
179
|
+
* Register or update a `package`-strategy module entry in modules.yaml so the
|
|
180
|
+
* published source has on-disk provenance (AC4). Returns the updated manifest.
|
|
181
|
+
* Pure (operates on an in-memory manifest); the disk read/write is done by the
|
|
182
|
+
* caller via `recordProvenance`.
|
|
183
|
+
*/
|
|
184
|
+
export function upsertPackProvenance(manifest, entry) {
|
|
185
|
+
const next = manifest ?? { version: '1', modules: [] };
|
|
186
|
+
const idx = next.modules.findIndex((m) => m.name === entry.name);
|
|
187
|
+
if (idx >= 0) {
|
|
188
|
+
next.modules[idx] = entry;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
next.modules.push(entry);
|
|
192
|
+
}
|
|
193
|
+
return next;
|
|
194
|
+
}
|
|
195
|
+
/** Read modules.yaml, upsert the provenance entry, write it back. */
|
|
196
|
+
export function recordProvenance(hqRoot, pkg, source) {
|
|
197
|
+
const entry = {
|
|
198
|
+
name: pkg.name,
|
|
199
|
+
strategy: 'package',
|
|
200
|
+
source,
|
|
201
|
+
version: pkg.version,
|
|
202
|
+
installed_at_iso: new Date().toISOString(),
|
|
203
|
+
// PackManifest.access is 'public'|'private'; module AccessLevel uses
|
|
204
|
+
// 'public'|'team'|'role:*'. A private pack maps to team scope.
|
|
205
|
+
access: pkg.access === 'private' ? 'team' : 'public',
|
|
206
|
+
};
|
|
207
|
+
const updated = upsertPackProvenance(readManifest(hqRoot), entry);
|
|
208
|
+
writeManifest(hqRoot, updated);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Unwrap the `POST /v1/listings` SUCCESS envelope. The deployed server returns
|
|
212
|
+
* the created listing WRAPPED: `{ listing: { listingId, creatorHandle, status,
|
|
213
|
+
* … } }`. Older/mocked responses return the listing fields at the TOP level.
|
|
214
|
+
* This reads `body.listing` when present and falls back to the top-level `body`
|
|
215
|
+
* for backwards-compat. Pure.
|
|
216
|
+
*/
|
|
217
|
+
export function unwrapListing(body) {
|
|
218
|
+
const inner = body?.listing;
|
|
219
|
+
if (inner && typeof inner === 'object') {
|
|
220
|
+
return inner;
|
|
221
|
+
}
|
|
222
|
+
return body;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Map a `POST /v1/listings` response into a user-facing success notice. Pure.
|
|
226
|
+
* Throws a clear error on a duplicate (409) or any non-2xx status so the caller
|
|
227
|
+
* prints it and exits non-zero.
|
|
228
|
+
*
|
|
229
|
+
* The notice uses the REAL listing id and the SERVER-resolved `creatorHandle`
|
|
230
|
+
* from the (possibly `{ listing }`-wrapped) response body — not the
|
|
231
|
+
* locally-resolved author handle — falling back gracefully when the server
|
|
232
|
+
* omits either field.
|
|
233
|
+
*/
|
|
234
|
+
export function buildListingNotice(status, body, packName, packVersion) {
|
|
235
|
+
if (status === 409) {
|
|
236
|
+
throw new Error(`${packName}@${packVersion} is already published (duplicate version). ` +
|
|
237
|
+
`Bump the version in package.yaml and re-run \`hq publish\`.`);
|
|
238
|
+
}
|
|
239
|
+
if (status === 401 || status === 403) {
|
|
240
|
+
throw new Error('Not authorized to publish — run `hq login` and ensure your creator account is verified.');
|
|
241
|
+
}
|
|
242
|
+
if (status < 200 || status >= 300) {
|
|
243
|
+
const msg = body.error ?? body.message ?? `HTTP ${status}`;
|
|
244
|
+
throw new Error(`Publish failed: ${msg}`);
|
|
245
|
+
}
|
|
246
|
+
const listing = unwrapListing(body);
|
|
247
|
+
const id = listing.listingId ?? listing.id ?? '(unknown id)';
|
|
248
|
+
const listingStatus = listing.status ?? 'pending_review';
|
|
249
|
+
const serverHandle = listing.creatorHandle?.trim();
|
|
250
|
+
const attribution = serverHandle ? ` Attributed to @${serverHandle}.` : '';
|
|
251
|
+
return `Published ${packName}@${packVersion} — listing ${id} (${listingStatus}).${attribution}`;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Core publish logic, dependency-injected so it can be unit-tested with mocked
|
|
255
|
+
* network + a tmp filesystem (no real API, no browser login). Returns the
|
|
256
|
+
* success notice; throws (uploading nothing) on logged-out or validation
|
|
257
|
+
* failure.
|
|
258
|
+
*/
|
|
259
|
+
export async function runPublish(targetPath, deps) {
|
|
260
|
+
// 1. Logged-in gate — refuse BEFORE any packing/upload.
|
|
261
|
+
const idToken = deps.loadIdToken();
|
|
262
|
+
if (!idToken) {
|
|
263
|
+
throw new Error('Not logged in — run `hq login` before publishing. Nothing was uploaded.');
|
|
264
|
+
}
|
|
265
|
+
const author = resolveAuthor(peekIdToken(idToken));
|
|
266
|
+
// Resolve a payload dir. We stamp/validate against a temp copy so we never
|
|
267
|
+
// mutate the user's working tree's package.yaml.
|
|
268
|
+
const absTarget = path.resolve(process.cwd(), targetPath);
|
|
269
|
+
if (!fs.existsSync(absTarget) || !fs.statSync(absTarget).isDirectory()) {
|
|
270
|
+
throw new Error(`Not a directory: ${absTarget}`);
|
|
271
|
+
}
|
|
272
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-publish-'));
|
|
273
|
+
const payloadDir = path.join(workDir, 'payload');
|
|
274
|
+
try {
|
|
275
|
+
fs.cpSync(absTarget, payloadDir, { recursive: true });
|
|
276
|
+
// 2. Stamp author (AC1) BEFORE validation so the stamped author is what
|
|
277
|
+
// gets validated, packed, and recorded.
|
|
278
|
+
if (!fs.existsSync(path.join(payloadDir, 'package.yaml'))) {
|
|
279
|
+
throw new Error(`package.yaml missing from ${absTarget} — nothing was uploaded.`);
|
|
280
|
+
}
|
|
281
|
+
stampAuthorIntoPackage(payloadDir, author);
|
|
282
|
+
// 3. Validate with the canonical 10-rule validator. Throws on any failure;
|
|
283
|
+
// we have uploaded nothing at this point.
|
|
284
|
+
const pkg = validateManifest(payloadDir, deps.hqVersion);
|
|
285
|
+
// 4. Tar + base64 + authenticated JSON upload. The deployed server
|
|
286
|
+
// JSON.parses the body, so we send the gzip tarball base64-encoded
|
|
287
|
+
// inside a JSON listing request (NOT a raw octet-stream body).
|
|
288
|
+
const tarballBytes = tarballPayload(payloadDir);
|
|
289
|
+
const token = await deps.getAccessToken();
|
|
290
|
+
const listing = {
|
|
291
|
+
type: inferListingType(pkg),
|
|
292
|
+
name: pkg.name,
|
|
293
|
+
slug: deriveSlug(pkg.name),
|
|
294
|
+
version: pkg.version,
|
|
295
|
+
summary: pkg.description,
|
|
296
|
+
contributes: summarizeContributes(pkg.contributes) || undefined,
|
|
297
|
+
// Server prefers the caller's claimed creator handle, but we stamp our
|
|
298
|
+
// resolved handle as a backwards-compat fallback.
|
|
299
|
+
creatorHandle: author.handle,
|
|
300
|
+
tarball: Buffer.from(tarballBytes).toString('base64'),
|
|
301
|
+
};
|
|
302
|
+
const { status, body } = await deps.upload({ token, listing });
|
|
303
|
+
const notice = buildListingNotice(status, body, pkg.name, pkg.version);
|
|
304
|
+
// Unwrap the `{ listing }` success envelope the server sends so attribution
|
|
305
|
+
// reads the server's resolved handle (fall back to top-level for compat).
|
|
306
|
+
const serverHandle = unwrapListing(body).creatorHandle?.trim();
|
|
307
|
+
// 5. Provenance (only after a successful upload).
|
|
308
|
+
recordProvenance(deps.hqRoot, pkg, absTarget);
|
|
309
|
+
return { notice, author, pkg, serverAttributed: Boolean(serverHandle) };
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Command wiring
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
function readHqVersion(hqRoot) {
|
|
319
|
+
const p = path.join(hqRoot, 'core.yaml');
|
|
320
|
+
if (!fs.existsSync(p))
|
|
321
|
+
return null;
|
|
322
|
+
try {
|
|
323
|
+
const c = yaml.load(fs.readFileSync(p, 'utf-8'));
|
|
324
|
+
return c?.hqVersion ?? null;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
export function registerPublishCommand(program) {
|
|
331
|
+
program
|
|
332
|
+
.command('publish <path>')
|
|
333
|
+
.description('Package a skill/worker pack and submit it to the HQ marketplace (POST /v1/listings).')
|
|
334
|
+
.action(async (targetPath) => {
|
|
335
|
+
try {
|
|
336
|
+
const hqRoot = findHqRoot();
|
|
337
|
+
const result = await runPublish(targetPath, {
|
|
338
|
+
loadIdToken: () => {
|
|
339
|
+
const cached = loadCachedTokens();
|
|
340
|
+
if (!cached || isExpiring(cached, 0))
|
|
341
|
+
return null;
|
|
342
|
+
return cached.idToken;
|
|
343
|
+
},
|
|
344
|
+
getAccessToken: () => ensureCognitoToken({ interactive: false }),
|
|
345
|
+
upload: async ({ token, listing }) => {
|
|
346
|
+
// JSON body (application/json) — the deployed handler JSON.parses
|
|
347
|
+
// the body and base64-decodes `listing.tarball`. vaultApiFetch
|
|
348
|
+
// sends application/json + the bearer token.
|
|
349
|
+
const res = await vaultApiFetch({
|
|
350
|
+
token,
|
|
351
|
+
path: '/v1/listings',
|
|
352
|
+
method: 'POST',
|
|
353
|
+
body: listing,
|
|
354
|
+
});
|
|
355
|
+
const json = (await res.json().catch(() => ({})));
|
|
356
|
+
return { status: res.status, body: json };
|
|
357
|
+
},
|
|
358
|
+
hqRoot,
|
|
359
|
+
hqVersion: readHqVersion(hqRoot),
|
|
360
|
+
});
|
|
361
|
+
console.log(chalk.green(result.notice));
|
|
362
|
+
// Only print the locally-resolved author as a fallback when the server
|
|
363
|
+
// did NOT return its own resolved handle (already in the notice).
|
|
364
|
+
if (!result.serverAttributed) {
|
|
365
|
+
console.log(chalk.dim(` Attributed to ${result.author.displayName} (@${result.author.handle}).`));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
console.error(chalk.red('Error:'), err instanceof Error ? err.message : String(err));
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
//# sourceMappingURL=publish.js.map
|
|
375
|
+
//# debugId=73704bc7-d7b1-5c4c-a34e-54edadcc2753
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq rescue — re-sync the local HQ core to an upstream hq-core release (or the
|
|
3
|
+
* staging branch) WITHOUT destroying local edits ("drift").
|
|
4
|
+
*
|
|
5
|
+
* Thin driver over @indigoai-us/hq-cloud's `rescue()`, which execs the bundled
|
|
6
|
+
* scripts/replace-rescue.sh against the HQ root. This is the CLI sibling of the
|
|
7
|
+
* HQ Sync menubar app's "Update / Restore" pill — both drive the exact same
|
|
8
|
+
* rescue script, just resolved from the shared hq-cloud package.
|
|
9
|
+
*
|
|
10
|
+
* Prod (default): resolves the latest `indigoai-us/hq-core` release tag and
|
|
11
|
+
* pins the three-way history floor to the commit of the user's currently
|
|
12
|
+
* installed version (read from core/core.yaml). Staging (`--staging`): targets
|
|
13
|
+
* `indigoai-us/hq-core-staging@main` and lets the script read its on-disk
|
|
14
|
+
* sync stamp for the floor.
|
|
15
|
+
*/
|
|
16
|
+
import { Command } from 'commander';
|
|
17
|
+
export interface RescueTarget {
|
|
18
|
+
source: string;
|
|
19
|
+
/** undefined → let the script apply its own default ref (`main`). */
|
|
20
|
+
ref?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the source repo + ref from the user's flags. Pure + exported for
|
|
24
|
+
* tests. `latestTag` is the resolved latest release tag (prod only); ignored
|
|
25
|
+
* for staging and when an explicit `--ref` is given.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveRescueTarget(opts: {
|
|
28
|
+
staging?: boolean;
|
|
29
|
+
source?: string;
|
|
30
|
+
ref?: string;
|
|
31
|
+
}, latestTag?: string): RescueTarget;
|
|
32
|
+
export declare function registerRescueCommand(program: Command): void;
|
|
33
|
+
//# sourceMappingURL=rescue.d.ts.map
|